import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/navigation/host_app_channel.dart'; import '../../core/storage/draft_storage.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/utils/amount_utils.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/widgets/searchable_picker_sheet.dart'; import '../expense_apply/expense_apply_api.dart'; import 'overtime_api.dart'; import 'widgets/overtime_detail_dialog.dart'; class OvertimeCreatePage extends ConsumerStatefulWidget { final String? id; const OvertimeCreatePage({super.key, this.id}); @override ConsumerState createState() => _OvertimeCreatePageState(); } class _OvertimeCreatePageState extends ConsumerState { static const _draftKey = 'overtime_apply'; // ── 基本信息 ── String _billType = '工作日加班'; String _selectedDeptId = ''; String _selectedDeptName = ''; String _selectedSalesmanId = ''; String _selectedSalesmanName = ''; final _customerController = TextEditingController(); final _origBillNoController = TextEditingController(); bool _isClosed = false; final _feedbackController = TextEditingController(); final _remarkController = TextEditingController(); final _scrollCtrl = ScrollController(); // ── 明细 ── final List _details = []; // ── 草稿 ── late Future _draftFuture; bool _draftHandled = false; // ── 参考数据 ── List _departments = []; bool _firstBuild = true; bool _refDataLoading = true; bool _addingDetail = false; @override void initState() { super.initState(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, ), ); _departments = []; _refDataLoading = true; _draftFuture = DraftStorage.has(_draftKey); _loadRefData(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady()); } void _checkDataReady() { if (!_refDataLoading && mounted) { setState(() => _firstBuild = false); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } else if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady()); } } Future _loadRefData({bool showLoading = false}) async { if (showLoading) { LoadingDialog.show( context, text: AppLocalizations.of(context).get('dataLoading'), ); } try { final api = ref.read(overtimeApiProvider); final deps = await api.getDepartments(); if (!mounted) return; setState(() { _departments = deps; _refDataLoading = false; _autoSelectDept(); }); } catch (_) { if (!mounted) return; setState(() => _refDataLoading = false); } finally { if (showLoading && mounted) LoadingDialog.hide(context); } } void _autoSelectDept() { if (_selectedDeptId.isNotEmpty) return; final dep = HostAppChannel.dep; if (dep.isEmpty) return; final match = _departments.where((d) => d.dep == dep); if (match.isNotEmpty) { _selectedDeptId = match.first.dep; _selectedDeptName = match.first.name; } } @override void dispose() { _customerController.dispose(); _origBillNoController.dispose(); _feedbackController.dispose(); _remarkController.dispose(); _scrollCtrl.dispose(); super.dispose(); } // ═══ 草稿持久化 ═══ Future _restoreDraft() async { final data = await DraftStorage.load(_draftKey); if (data == null) return; setState(() { _billType = data['billType'] as String? ?? '工作日加班'; _selectedDeptId = data['selectedDeptId'] as String? ?? ''; _selectedDeptName = data['selectedDeptName'] as String? ?? ''; _selectedSalesmanId = data['selectedSalesmanId'] as String? ?? ''; _selectedSalesmanName = data['selectedSalesmanName'] as String? ?? ''; _customerController.text = data['customer'] as String? ?? ''; _origBillNoController.text = data['origBillNo'] as String? ?? ''; _isClosed = data['isClosed'] as bool? ?? false; _feedbackController.text = data['feedback'] as String? ?? ''; _remarkController.text = data['remark'] as String? ?? ''; _details.clear(); final detailList = data['details'] as List?; if (detailList != null) { for (final d in detailList) { final m = d as Map; _details.add( OvertimeDetailData( empCode: m['empCode'] as String? ?? '', shiftType: m['shiftType'] as String? ?? '', startDate: m['startDate'] as String? ?? '', endDate: m['endDate'] as String? ?? '', approvedHours: (m['approvedHours'] as num?)?.toDouble() ?? 0, isApproved: m['isApproved'] as String? ?? 'N', directOt: m['directOt'] as String? ?? 'N', otQuantity: (m['otQuantity'] as num?)?.toDouble() ?? 0, reason: m['reason'] as String? ?? '', handleMethod: m['handleMethod'] as String? ?? '', chargeMethod: m['chargeMethod'] as String? ?? '', remark: m['remark'] as String? ?? '', isClosed: m['isClosed'] as String? ?? 'N', outBillNo: m['outBillNo'] as String? ?? '', ), ); } } }); } Future _saveDraftToStorage() async { final detailList = _details .map( (d) => { 'empCode': d.empCode, 'shiftType': d.shiftType, 'startDate': d.startDate, 'endDate': d.endDate, 'approvedHours': d.approvedHours, 'isApproved': d.isApproved, 'directOt': d.directOt, 'otQuantity': d.otQuantity, 'reason': d.reason, 'handleMethod': d.handleMethod, 'chargeMethod': d.chargeMethod, 'remark': d.remark, 'isClosed': d.isClosed, 'outBillNo': d.outBillNo, }, ) .toList(); await DraftStorage.save(_draftKey, { 'billType': _billType, 'selectedDeptId': _selectedDeptId, 'selectedDeptName': _selectedDeptName, 'selectedSalesmanId': _selectedSalesmanId, 'selectedSalesmanName': _selectedSalesmanName, 'customer': _customerController.text, 'origBillNo': _origBillNoController.text, 'isClosed': _isClosed, 'feedback': _feedbackController.text, 'remark': _remarkController.text, 'details': detailList, }); } // ═══ 草稿弹窗 ═══ void _showDraftDialog() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; FocusManager.instance.primaryFocus?.unfocus(); showDialog( context: context, barrierDismissible: false, builder: (ctx) => TDAlertDialog( title: l10n.get('draftFound'), content: l10n.get('draftRestorePrompt'), leftBtn: TDDialogButtonOptions( title: l10n.get('discard'), titleColor: colors.textSecondary, action: () { Navigator.pop(ctx); DraftStorage.delete(_draftKey); }, ), rightBtn: TDDialogButtonOptions( title: l10n.get('restore'), titleColor: colors.primary, action: () { Navigator.pop(ctx); _restoreDraft(); }, ), ), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); if (_firstBuild) { return const SkeletonFormPage(); } Future.microtask( () => ref.read(pageBackProvider.notifier).state = () => _doPop(), ); return FutureBuilder( future: _draftFuture, builder: (ctx, snapshot) { final hasDraft = snapshot.hasData && snapshot.data == true; if (hasDraft && !_draftHandled) { _draftHandled = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showDraftDialog(); }); } Future.microtask( () => ref.read(pageBackProvider.notifier).state = () => _doPop(), ); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; _doPop(); }, child: Column( children: [ Expanded( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), const SizedBox(height: 16), _buildDetailsSection(l10n), const SizedBox(height: 24), _buildPageFooter(), ], ), ), ), ), _buildBottomBar(l10n), ], ), ); }, ); } // ═══ 1. 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applyDept'), value: _selectedDeptId.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : '', hint: l10n.get('pleaseSelect'), onTap: _refDataLoading ? null : () => _showDeptPicker(), ), const SizedBox(height: 16), _buildBillTypeRow(l10n), const SizedBox(height: 16), FormFieldRow( label: '业务员', value: _selectedSalesmanId.isNotEmpty ? '$_selectedSalesmanId/$_selectedSalesmanName' : '', hint: l10n.get('pleaseSelect'), onTap: () => _showSalesmanPicker(), ), const SizedBox(height: 16), FormFieldRow( label: '报修客户', value: _customerController.text, hint: '请输入', onTap: () => _showTextInput( '报修客户', (v) => setState(() { _customerController.text = v; _customerController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }), initialText: _customerController.text, ), ), const SizedBox(height: 16), FormFieldRow( label: '原加班单号', value: _origBillNoController.text, hint: '请输入', onTap: () => _showTextInput( '原加班单号', (v) => setState(() { _origBillNoController.text = v; _origBillNoController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }), initialText: _origBillNoController.text, ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '结案', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), TDSwitch( isOn: _isClosed, onChanged: (v) { setState(() => _isClosed = v); return false; }, ), ], ), const SizedBox(height: 16), _label(l10n.get('reminderTitle')), const SizedBox(height: 8), TDTextarea( controller: _feedbackController, hintText: '客户反馈', maxLines: 3, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), const SizedBox(height: 16), _label(l10n.get('remark')), const SizedBox(height: 8), TDTextarea( controller: _remarkController, hintText: l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), ], ); } Widget _buildBillTypeRow(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; final types = ['工作日加班', '休息日加班', '节假日加班']; return Row( children: [ Text( '单据类别', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), const SizedBox(width: 8), Expanded( child: Wrap( alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 12, runSpacing: 8, children: types.map((t) { final sel = _billType == t; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _billType = t), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: sel ? colors.primary : colors.textPlaceholder, width: 2, ), ), child: sel ? Center( child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: colors.primary, ), ), ) : null, ), const SizedBox(width: 5), Text( t, style: TextStyle( fontSize: AppFontSizes.subtitle, color: sel ? colors.primary : colors.textPrimary, ), ), ], ), ); }).toList(), ), ), ], ); } // ═══ 2. 明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: '加班明细', leadingIcon: Icons.receipt_long_outlined, showAction: true, actionText: '添加', onActionTap: _showDetailDialog, children: [ if (_details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( '请添加加班明细', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ..._details.asMap().entries.map((e) { final d = e.value; return GestureDetector( onTap: () => _showDetailDialog(editIndex: e.key), child: Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( '${d.empCode}${d.shiftType.isNotEmpty ? ' · ${d.shiftType}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPrimary, ), ), ), const SizedBox(width: 12), Text( formatAmount(d.approvedHours), style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), ], ), if (d.startDate.isNotEmpty && d.endDate.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${d.startDate} ~ ${d.endDate}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.reason.isNotEmpty) ...[ const SizedBox(height: 4), Text( d.reason, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.isApproved == 'Y') ...[ const SizedBox(height: 4), Text( '已核准', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.success, ), ), ], ], ), ), const SizedBox(width: 8), GestureDetector( onTap: () => setState(() => _details.removeAt(e.key)), child: Icon( Icons.close, size: 18, color: colors.textSecondary, ), ), ], ), ), ); }), const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '合计', style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( formatAmount( _details.fold(0, (s, d) => s + d.approvedHours), ), style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), ], ); } Future _showDetailDialog({int? editIndex}) async { if (_addingDetail) return; _addingDetail = true; try { final l10n = AppLocalizations.of(context); OvertimeDetailData? initialData; if (editIndex != null) { initialData = _details[editIndex]; } FocusManager.instance.primaryFocus?.unfocus(); final result = await OvertimeDetailDialog.show( // ignore: use_build_context_synchronously context, l10n: l10n, initialData: initialData, ); if (result != null && mounted) { setState(() { if (editIndex != null) { _details[editIndex] = result; } else { _details.add(result); } }); } } finally { _addingDetail = false; } } // ═══ 3. 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { return ActionBar( showLeft: false, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submit'), centerTextOnly: true, onCenterTap: () async { FocusScope.of(context).unfocus(); try { await _saveDraftToStorage(); if (mounted) _forcePop(); } catch (_) { if (mounted) { TDToast.showFail(l10n.get('saveFailed'), context: context); } } }, onRightTap: () async { FocusScope.of(context).unfocus(); LoadingDialog.show(context, text: l10n.get('submitting')); try { final data = _buildSubmitData(); final api = ref.read(overtimeApiProvider); await api.submit(data); await DraftStorage.delete(_draftKey); if (mounted) { LoadingDialog.hide(context); TDToast.showSuccess(l10n.get('submitSuccess'), context: context); GoRouter.of(context).go('/overtime/list'); } } catch (e) { if (mounted) { LoadingDialog.hide(context); TDToast.showFail(l10n.get('submitFailedRetry'), context: context); } } }, ); } Map _buildSubmitData() { return { 'HeadData': { 'BILL_TYPE': _billType, 'DEP': _selectedDeptId, 'SALESMAN': _selectedSalesmanId, 'CUSTOMER': _customerController.text, 'ORIG_BILL_NO': _origBillNoController.text, 'IS_CLOSED': _isClosed ? 1 : 0, 'FEEDBACK': _feedbackController.text, 'REMARK': _remarkController.text, 'USR': HostAppChannel.usr, }, 'BodyData1': _details.asMap().entries.map((e) { final d = e.value; return { 'SEQ_NO': e.key + 1, 'EMP_CODE': d.empCode, 'SHIFT_TYPE': d.shiftType, 'START_DATE': d.startDate, 'END_DATE': d.endDate, 'APPROVED_HOURS': d.approvedHours, 'IS_APPROVED': d.isApproved, 'DIRECT_OT': d.directOt, 'OT_QUANTITY': d.otQuantity, 'REASON': d.reason, 'HANDLE_METHOD': d.handleMethod, 'CHARGE_METHOD': d.chargeMethod, 'REMARK': d.remark, 'IS_CLOSED': d.isClosed, 'OUT_BILL_NO': d.outBillNo, }; }).toList(), }; } void _doPop() { if (_hasUnsaved()) { final l10n = AppLocalizations.of(context); _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () async { await DraftStorage.delete(_draftKey); if (!mounted) return; setState(() => _clearLocalState()); _forcePop(); }, ); } else { _forcePop(); } } void _forcePop() { FocusManager.instance.primaryFocus?.unfocus(); final router = GoRouter.of(context); if (router.canPop()) { router.pop(); } else { SystemNavigator.pop(); } } bool _hasUnsaved() => _customerController.text.isNotEmpty || _origBillNoController.text.isNotEmpty || _feedbackController.text.isNotEmpty || _remarkController.text.isNotEmpty || _details.isNotEmpty || _billType != '工作日加班' || _selectedDeptId.isNotEmpty || _selectedSalesmanId.isNotEmpty || _isClosed; void _clearLocalState() { _billType = '工作日加班'; _selectedDeptId = ''; _selectedDeptName = ''; _selectedSalesmanId = ''; _selectedSalesmanName = ''; _customerController.clear(); _origBillNoController.clear(); _isClosed = false; _feedbackController.clear(); _remarkController.clear(); _details.clear(); } void _unfocus() => FocusScope.of(context).unfocus(); void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { _unfocus(); FocusManager.instance.primaryFocus?.unfocus(); final colors = Theme.of(context).extension()!; showDialog( context: context, useRootNavigator: true, builder: (ctx) => TDAlertDialog( title: title, content: content, buttonStyle: TDDialogButtonStyle.text, leftBtn: TDDialogButtonOptions( title: leftText, titleColor: colors.primary, action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: rightText, titleColor: colors.danger, action: () { Navigator.pop(ctx); onConfirm(); }, ), ), ); } // ═══ Picker 方法 ═══ Future _showDeptPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(overtimeApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('applyDept')}', searchHint: l10n.get('search'), loader: (keyword, page) => api.getDepartments(keyword: keyword, page: page, size: 20), labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}', onRefresh: () => api.clearRefCache(), ); if (result != null && mounted) { setState(() { _selectedDeptId = result.dep; _selectedDeptName = result.name; }); } } Future _showSalesmanPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(overtimeApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}业务员', searchHint: l10n.get('search'), loader: (keyword, page) => api.getEmployees(keyword: keyword, page: page, size: 20), labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}', onRefresh: () => api.clearRefCache(), ); if (result != null && mounted) { setState(() { _selectedSalesmanId = result.salNo; _selectedSalesmanName = result.name; }); } } void _showTextInput( String title, Function(String) onConfirm, { String initialText = '', }) { final l10n = AppLocalizations.of(context); _unfocus(); FocusManager.instance.primaryFocus?.unfocus(); final c = TextEditingController(text: initialText); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog( textEditingController: c, title: title, hintText: l10n.get('pleaseEnter'), leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); }, ), ), ); } Widget _label(String t, {bool required = false}) { final colors = Theme.of(context).extension()!; return Text.rich( TextSpan( children: [ TextSpan( text: t, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), if (required) TextSpan( text: ' *', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.danger, ), ), ], ), ); } Widget _buildPageFooter() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; 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, ), ), ], ), ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } }