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 '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../shared/widgets/searchable_picker_sheet.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/helpers/audit_flow_helper.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import 'overtime_apply_api.dart'; import 'overtime_apply_model.dart'; import 'overtime_apply_list_controller.dart'; import '../expense_apply/expense_apply_api.dart'; import 'widgets/overtime_apply_detail_dialog.dart'; class OvertimeApplyCreatePage extends ConsumerStatefulWidget { final String? id; const OvertimeApplyCreatePage({super.key, this.id}); @override ConsumerState createState() => _OvertimeApplyCreatePageState(); } class _OvertimeApplyCreatePageState extends ConsumerState { static const _draftKey = 'overtime_apply'; // ── 基本信息 ── final _reasonController = TextEditingController(); final _reasonFocus = FocusNode(); final _remarkController = TextEditingController(); final _remarkFocus = FocusNode(); final _scrollCtrl = ScrollController(); // ── 加班明细 ── final List<_DetailItem> _details = []; int _detailIdCounter = 1; // ── 草稿 ── late Future _draftFuture; bool _draftHandled = false; // ── 参考数据(从 API 加载) ── bool _firstBuild = true; bool _refDataLoading = true; bool _addingDetail = false; // ── 申请部门 ── String _selectedDeptId = ''; String _selectedDeptName = ''; List _departments = []; // ── 申请人 ── String _selectedApplicantId = ''; String _selectedApplicantName = ''; List _employees = []; @override void initState() { super.initState(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, ), ); _reasonFocus.addListener(() => _ensureVisible(_reasonFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _departments = []; _refDataLoading = true; _refDataFuture = null; _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? _refDataFuture; Future _loadRefData({bool showLoading = false}) async { if (_refDataFuture != null) return _refDataFuture!; final completer = Completer(); _refDataFuture = completer.future; if (showLoading) { LoadingDialog.show( context, text: AppLocalizations.of(context).get('dataLoading'), ); } try { final api = ref.read(overtimeApplyApiProvider); final results = await Future.wait([ api.getDepartments(), api.getEmployees(), ]); if (!mounted) return; setState(() { _departments = results[0] as List; _employees = results[1] as List; _refDataLoading = false; _autoSelectDept(); _autoSelectApplicant(); }); completer.complete(); } catch (_) { if (!mounted) { completer.complete(); return; } setState(() => _refDataLoading = false); completer.complete(); } finally { if (showLoading && mounted) LoadingDialog.hide(context); _refDataFuture = null; } } 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; } } void _autoSelectApplicant() { if (_selectedApplicantId.isNotEmpty) return; final usr = HostAppChannel.usr; if (usr.isEmpty) return; final match = _employees.where((e) => e.salNo == usr); if (match.isNotEmpty) { _selectedApplicantId = match.first.salNo; _selectedApplicantName = match.first.name; } } Future _showApplicantPicker() async { FocusScope.of(context).unfocus(); FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(overtimeApplyApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('applicant')}', 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}', ); if (result != null && mounted) { setState(() { _selectedApplicantId = result.salNo; _selectedApplicantName = result.name; }); } } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (node.hasFocus && _scrollCtrl.hasClients) { final ctx = node.context; if (ctx != null) { Scrollable.ensureVisible( ctx, alignment: 0.3, duration: const Duration(milliseconds: 300), ); } } }); } @override void dispose() { _reasonController.dispose(); _reasonFocus.dispose(); _remarkController.dispose(); _remarkFocus.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); if (_firstBuild) { return const SkeletonFormPage(sectionRows: [5, 3], bottomButtonCount: 2); } Future.microtask( () => ref.read(pageBackProvider.notifier).state = () => _doPop(), ); final pageContent = 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), ], ), ); 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(); }); } return pageContent; }, ); } // ═══ 草稿持久化 ═══ Future _restoreDraft() async { final data = await DraftStorage.load(_draftKey); if (data == null) return; try { final model = OvertimeApplyModel.fromJson(data); setState(() { _reasonController.text = model.reason; _remarkController.text = model.rem; _selectedDeptId = model.dep; _selectedDeptName = model.depName; _selectedApplicantId = model.salNo; _selectedApplicantName = model.salName; _details.clear(); for (final d in model.details) { _details.add( _DetailItem( id: _detailIdCounter++, jbNo: d.jbNo, itm: d.itm, salNo: d.salNo, salName: d.salName, dep: d.dep, depName: d.depName, jbType: d.jbType, jbDate: d.jbDate != null ? '${d.jbDate!.year}-${d.jbDate!.month.toString().padLeft(2, '0')}-${d.jbDate!.day.toString().padLeft(2, '0')}' : '', startTime: d.startTime != null ? '${d.startTime!.year}-${d.startTime!.month.toString().padLeft(2, '0')}-${d.startTime!.day.toString().padLeft(2, '0')} ' '${d.startTime!.hour.toString().padLeft(2, '0')}:${d.startTime!.minute.toString().padLeft(2, '0')}' : '', endTime: d.endTime != null ? '${d.endTime!.year}-${d.endTime!.month.toString().padLeft(2, '0')}-${d.endTime!.day.toString().padLeft(2, '0')} ' '${d.endTime!.hour.toString().padLeft(2, '0')}:${d.endTime!.minute.toString().padLeft(2, '0')}' : '', jbHours: d.jbHours, jbDays: d.jbDays, attPeriod: d.attPeriod, reason: d.reason, compensationType: d.compensationType, compensationCount: d.compensationCount, adr: d.adr, rem: d.rem, ), ); } _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1; }); } catch (_) { // 旧格式草稿兼容 } } Future _saveDraftToStorage() async { final now = DateTime.now(); final model = OvertimeApplyModel( jbNo: '', jbDd: now, salNo: _selectedApplicantId, salName: _selectedApplicantName, dep: _selectedDeptId, depName: _selectedDeptName, reason: _reasonController.text, rem: _remarkController.text, usr: HostAppChannel.usr, recordDd: now, details: _details .map( (d) => OvertimeApplyDetailModel( itm: d.itm, jbNo: d.jbNo, salNo: d.salNo, salName: d.salName, dep: d.dep, depName: d.depName, jbType: d.jbType, jbDate: d.jbDate.isNotEmpty ? DateTime.tryParse(d.jbDate) : null, startTime: d.startTime.isNotEmpty ? DateTime.tryParse(d.startTime) : null, endTime: d.endTime.isNotEmpty ? DateTime.tryParse(d.endTime) : null, jbHours: d.jbHours, jbDays: d.jbDays, attPeriod: d.attPeriod, reason: d.reason, compensationType: d.compensationType, compensationCount: d.compensationCount, adr: d.adr, rem: d.rem, ), ) .toList(), ); await DraftStorage.save(_draftKey, model.toJson()); } // ═══ 草稿弹窗 ═══ 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'), buttonStyle: TDDialogButtonStyle.text, 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(); }, ), ), ); } // ═══ 1. 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; final now = DateTime.now(); final todayStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('date'), value: todayStr, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('dep'), value: _selectedDeptId.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : '', hint: l10n.get('pleaseSelect'), onTap: _refDataLoading ? null : () => _showDeptPicker(), ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applicant'), required: true, value: _selectedApplicantId.isNotEmpty ? '$_selectedApplicantId/$_selectedApplicantName' : '', hint: l10n.get('pleaseSelect'), onTap: () => _showApplicantPicker(), ), const SizedBox(height: 16), _label(l10n.get('overtimeReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _reasonController, focusNode: _reasonFocus, hintText: l10n.get('enterOvertimeReason'), maxLines: 4, minLines: 1, maxLength: 1000, 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, focusNode: _remarkFocus, hintText: l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), ], ); } // ═══ 2. 加班明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('overtimeDetails'), leadingIcon: Icons.access_time_outlined, showAction: true, actionText: l10n.get('add'), onActionTap: _showDetailDialog, children: [ if (_details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), 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: 6), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded( 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, ), _detailLabel( '${l10n.get('jbDate')}: ${d.jbDate}', colors, trailing: _weekdayLabel(d.jbDate, l10n) != null ? _weekdayTag(_weekdayLabel(d.jbDate, l10n)!) : null, ), ], if (d.startTime.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel( '${l10n.get('startTime')}: ${_fmtTime(d.startTime)}', colors, ), ], if (d.endTime.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel( '${l10n.get('endTime')}: ${_fmtTime(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, ), ], ], ), ), 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( l10n.get('totalOvertimeHours'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '${_totalHours().toStringAsFixed(1)}${l10n.get('hours')}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.timePrimary, ), ), ], ), ), ], ); } double _totalHours() => _details.fold(0.0, (s, d) => s + d.jbHours); 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 _fmtTime(String dt) { if (dt.length >= 16) return dt.substring(11, 16); return dt; } String? _weekdayLabel(String dateTimeStr, AppLocalizations l10n) { final dt = DateTime.tryParse(dateTimeStr); if (dt == null) return null; 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 null; } } 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 _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, ), ); } Future _showDetailDialog({int? editIndex}) async { if (_addingDetail) return; _addingDetail = true; try { final l10n = AppLocalizations.of(context); OvertimeDetailData? initialData; if (editIndex != null) { final d = _details[editIndex]; initialData = OvertimeDetailData( jbNo: d.jbNo, itm: d.itm > 0 ? d.itm : null, salNo: d.salNo, salName: d.salName, dep: d.dep, depName: d.depName, jbType: d.jbType, jbDate: d.jbDate, startTime: d.startTime, endTime: d.endTime, jbHours: d.jbHours, jbDays: d.jbDays, attPeriod: d.attPeriod, reason: d.reason, compensationType: d.compensationType, compensationCount: d.compensationCount, adr: d.adr, rem: d.rem, ); } FocusManager.instance.primaryFocus?.unfocus(); final result = await OvertimeApplyDetailDialog.show( // ignore: use_build_context_synchronously context, api: ref.read(overtimeApplyApiProvider), l10n: l10n, initialData: initialData, ); if (result != null && mounted) { setState(() { final item = _DetailItem( id: editIndex != null ? _details[editIndex].id : _detailIdCounter++, jbNo: result.jbNo, itm: result.itm ?? 0, salNo: result.salNo, salName: result.salName, dep: result.dep, depName: result.depName, jbType: result.jbType, jbDate: result.jbDate, startTime: result.startTime, endTime: result.endTime, jbHours: result.jbHours, jbDays: result.jbDays, attPeriod: result.attPeriod, reason: result.reason, compensationType: result.compensationType, compensationCount: result.compensationCount, adr: result.adr, rem: result.rem, ); if (editIndex != null) { _details[editIndex] = item; } else { _details.add(item); } }); } } 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 { // 点提交先收键盘清焦点,避免输入框残留焦点导致键盘自动弹出 FocusManager.instance.primaryFocus?.unfocus(); FocusScope.of(context).unfocus(); final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } LoadingDialog.show(context, text: l10n.get('submitting')); try { final data = _buildSubmitData(); final api = ref.read(overtimeApplyApiProvider); final billNo = await api.submit(data); await DraftStorage.delete(_draftKey); if (!mounted) return; LoadingDialog.hide(context); if (billNo != null) { final dd = data['HeadData']['JB_DD']?.toString() ?? ''; await AuditFlowHelper.handle( context: context, l10n: l10n, getConfig: () => api.getBillAuditConfig('JB'), onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd), ); } if (mounted) { TDToast.showSuccess(l10n.get('billCreated'), context: context); ref.read(overtimeApplyRefreshProvider.notifier).state++; GoRouter.of(context).go('/overtime-apply/list'); } } catch (e) { if (mounted) LoadingDialog.hide(context); } }, ); } Map _buildSubmitData() { final now = DateTime.now(); final jbDd = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; return { 'HeadData': { 'JB_DD': jbDd, 'SAL_NO': _selectedApplicantId.isNotEmpty ? _selectedApplicantId : HostAppChannel.usr, 'DEP': _selectedDeptId, 'REASON': _reasonController.text.trim(), 'REM': _remarkController.text, 'USR': HostAppChannel.usr, }, 'BodyData1': _details.asMap().entries.map((e) { final d = e.value; return { 'ITM': e.key + 1, 'SAL_NO': d.salNo, 'DEP': d.dep.isNotEmpty ? d.dep : _selectedDeptId, 'JB_TYPE': d.jbType, 'JB_DATE': d.jbDate, 'START_TIME': d.startTime, 'END_TIME': d.endTime, 'JB_HOURS': d.jbHours, 'JB_DAYS': d.jbDays, 'ATT_PERIOD': d.attPeriod, 'REASON': d.reason, 'COMPENSATION_TYPE': d.compensationType, 'COMPENSATION_COUNT': d.compensationCount, 'ADR': d.adr, 'REM': d.rem, }; }).toList(), }; } List _validate(AppLocalizations l10n) { final e = []; if (_reasonController.text.trim().isEmpty) { e.add(l10n.get('enterOvertimeReason')); } if (_details.isEmpty) e.add(l10n.get('addAtLeastOneOTDetail')); if (_selectedDeptId.isEmpty) e.add(l10n.get('selectDept')); if (_selectedApplicantId.isEmpty) e.add(l10n.get('selectApplicant')); return e; } 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() => _reasonController.text.isNotEmpty || _details.isNotEmpty || _remarkController.text.isNotEmpty; void _clearLocalState() { _reasonController.clear(); _remarkController.clear(); _details.clear(); _detailIdCounter = 1; _selectedDeptId = ''; _selectedDeptName = ''; _selectedApplicantId = ''; _selectedApplicantName = ''; } void _unfocus() => FocusScope.of(context).unfocus(); void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { _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(); }, ), ), ); } 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, ), ), ], ), ), ); } Future _showDeptPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(overtimeApplyApiProvider); 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; }); } } } class _DetailItem { final int id; final String? jbNo; final int itm; final String salNo; final String salName; final String dep; final String depName; final String jbType; final String jbDate; final String startTime; final String endTime; final double jbHours; final double jbDays; final String attPeriod; final String reason; final String compensationType; final double compensationCount; final String adr; final String rem; const _DetailItem({ required this.id, this.jbNo, this.itm = 0, this.salNo = '', this.salName = '', this.dep = '', this.depName = '', this.jbType = 'WORKING_DAY', this.jbDate = '', this.startTime = '', this.endTime = '', this.jbHours = 0.0, this.jbDays = 0.0, this.attPeriod = '', this.reason = '', this.compensationType = '', this.compensationCount = 0.0, this.adr = '', this.rem = '', }); }