// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../../core/i18n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors_extension.dart'; /// 加班明细输入数据。 class OvertimeDetailData { final String empCode; final String shiftType; final String startDate; final String endDate; final double approvedHours; final String isApproved; final String directOt; final double otQuantity; final String reason; final String handleMethod; final String chargeMethod; final String remark; final String isClosed; final String outBillNo; const OvertimeDetailData({ required this.empCode, required this.shiftType, required this.startDate, required this.endDate, required this.approvedHours, required this.isApproved, required this.directOt, required this.otQuantity, required this.reason, required this.handleMethod, required this.chargeMethod, required this.remark, required this.isClosed, required this.outBillNo, }); } /// 加班明细录入弹窗。 class OvertimeDetailDialog extends StatefulWidget { final AppLocalizations l10n; final OvertimeDetailData? initialData; const OvertimeDetailDialog({super.key, required this.l10n, this.initialData}); /// 显示弹窗,返回 [OvertimeDetailData] 或 `null`(取消时)。 static Future show( BuildContext context, { required AppLocalizations l10n, OvertimeDetailData? initialData, }) { FocusScope.of(context).unfocus(); return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: true, builder: (_) => OvertimeDetailDialog(l10n: l10n, initialData: initialData), ), ); } @override State createState() => _OvertimeDetailDialogState(); } class _OvertimeDetailDialogState extends State { final _empCodeCtrl = TextEditingController(); final _shiftTypeCtrl = TextEditingController(); String _startDate = ''; String _endDate = ''; final _approvedHoursCtrl = TextEditingController(); String _isApproved = 'N'; String _directOt = 'N'; final _reasonCtrl = TextEditingController(); final _handleMethodCtrl = TextEditingController(); final _chargeMethodCtrl = TextEditingController(); final _remarkCtrl = TextEditingController(); String _isClosed = 'N'; final _outBillNoCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); AppLocalizations get _l10n => widget.l10n; bool get _isEdit => widget.initialData != null; @override void initState() { super.initState(); final d = widget.initialData; if (d != null) { _empCodeCtrl.text = d.empCode; _shiftTypeCtrl.text = d.shiftType; _startDate = d.startDate; _endDate = d.endDate; _approvedHoursCtrl.text = d.approvedHours > 0 ? d.approvedHours.toString() : ''; _isApproved = d.isApproved; _directOt = d.directOt; _reasonCtrl.text = d.reason; _handleMethodCtrl.text = d.handleMethod; _chargeMethodCtrl.text = d.chargeMethod; _remarkCtrl.text = d.remark; _isClosed = d.isClosed; _outBillNoCtrl.text = d.outBillNo; } } @override void dispose() { _empCodeCtrl.dispose(); _shiftTypeCtrl.dispose(); _approvedHoursCtrl.dispose(); _reasonCtrl.dispose(); _handleMethodCtrl.dispose(); _chargeMethodCtrl.dispose(); _remarkCtrl.dispose(); _outBillNoCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _confirm() { if (_empCodeCtrl.text.trim().isEmpty) { TDToast.showText('请输入员工代号', context: context); return; } final otQuantity = double.tryParse(_approvedHoursCtrl.text) ?? 0; Navigator.pop( context, OvertimeDetailData( empCode: _empCodeCtrl.text.trim(), shiftType: _shiftTypeCtrl.text, startDate: _startDate, endDate: _endDate, approvedHours: otQuantity, isApproved: _isApproved, directOt: _directOt, otQuantity: otQuantity, reason: _reasonCtrl.text, handleMethod: _handleMethodCtrl.text, chargeMethod: _chargeMethodCtrl.text, remark: _remarkCtrl.text, isClosed: _isClosed, outBillNo: _outBillNoCtrl.text, ), ); } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return AnimatedPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), duration: const Duration(milliseconds: 200), child: SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.85, ), child: Container( decoration: BoxDecoration( color: colors.bgPage, borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(colors), Flexible( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), behavior: HitTestBehavior.translucent, child: SingleChildScrollView( controller: _scrollCtrl, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.manual, padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _inputCard( label: '员工代号', required: true, controller: _empCodeCtrl, hintText: '请输入', ), const SizedBox(height: 12), _inputCard( label: '班别', required: false, controller: _shiftTypeCtrl, hintText: '请输入', ), const SizedBox(height: 12), _datePickerCard( label: '开始日期', value: _startDate, hint: '请选择', colors: colors, onPick: (d) => setState(() => _startDate = d), onClear: _startDate.isNotEmpty ? () => setState(() => _startDate = '') : null, ), const SizedBox(height: 12), _datePickerCard( label: '结束日期', value: _endDate, hint: '请选择', colors: colors, onPick: (d) => setState(() => _endDate = d), onClear: _endDate.isNotEmpty ? () => setState(() => _endDate = '') : null, ), const SizedBox(height: 12), _inputCard( label: '加班核准量', required: false, controller: _approvedHoursCtrl, hintText: '0', keyboardType: const TextInputType.numberWithOptions( decimal: true, ), inputFormatters: [ FilteringTextInputFormatter.allow( RegExp(r'^\d*\.?\d{0,2}$'), ), ], onChanged: () => setState(() {}), ), const SizedBox(height: 12), _toggleCard( label: '核准', value: _isApproved, options: const ['Y', 'N'], onChanged: (v) => setState(() => _isApproved = v), ), const SizedBox(height: 12), _toggleCard( label: '直接转加班', value: _directOt, options: const ['Y', 'N'], onChanged: (v) => setState(() => _directOt = v), ), const SizedBox(height: 12), // 加班数量(只读,=核准量) _readOnlyCard( label: '加班数量', value: _approvedHoursCtrl.text.isNotEmpty ? _approvedHoursCtrl.text : '0', ), const SizedBox(height: 12), _inputCard( label: '原因说明', required: false, controller: _reasonCtrl, hintText: '请输入', ), const SizedBox(height: 12), _inputCard( label: '处理方式', required: false, controller: _handleMethodCtrl, hintText: '请输入', ), const SizedBox(height: 12), _inputCard( label: '收费方式', required: false, controller: _chargeMethodCtrl, hintText: '请输入', ), const SizedBox(height: 12), _inputCard( label: '备注', required: false, controller: _remarkCtrl, hintText: '请输入', ), const SizedBox(height: 12), _toggleCard( label: '结案', value: _isClosed, options: const ['Y', 'N'], onChanged: (v) => setState(() => _isClosed = v), ), const SizedBox(height: 12), _inputCard( label: '转出单号', required: false, controller: _outBillNoCtrl, hintText: '请输入', ), ], ), ), ), ), Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration( color: colors.bgCard, border: Border( top: BorderSide(color: colors.border, width: 0.5), ), ), child: Row( children: [ Expanded( child: TDButton( text: _l10n.get('cancel'), size: TDButtonSize.large, type: TDButtonType.outline, shape: TDButtonShape.rectangle, theme: TDButtonTheme.defaultTheme, onTap: () => Navigator.pop(context), ), ), const SizedBox(width: 12), Expanded( child: TDButton( text: _isEdit ? '确认修改' : '添加', size: TDButtonSize.large, type: TDButtonType.fill, shape: TDButtonShape.rectangle, theme: TDButtonTheme.primary, onTap: _confirm, ), ), ], ), ), ], ), ), ), ), ); } // ── 标题栏 ── Widget _buildHeader(AppColorsExtension colors) { return Column( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( margin: const EdgeInsets.only(top: 8, bottom: 4), width: 36, height: 4, decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 12, 16), child: Row( children: [ const SizedBox(width: 24), Expanded( child: Center( child: Text( '加班明细', style: TextStyle( fontSize: AppFontSizes.title, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ), ), GestureDetector( onTap: () => Navigator.pop(context), child: Padding( padding: const EdgeInsets.all(4), child: Icon( Icons.close, size: 20, color: colors.textSecondary, ), ), ), ], ), ), ], ); } // ── 通用输入卡片 ── Widget _inputCard({ required String label, required bool required, required TextEditingController controller, required String hintText, TextInputType? keyboardType, List? inputFormatters, VoidCallback? onChanged, }) { final tdTheme = TDTheme.of(context); final hasValue = controller.text.isNotEmpty; return Container( padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TextField( controller: controller, textAlign: TextAlign.end, keyboardType: keyboardType, inputFormatters: inputFormatters, style: TextStyle( fontSize: 16, color: tdTheme.textColorPrimary, ), decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle( fontSize: 16, color: tdTheme.textColorPlaceholder, ), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: () { controller.clear(); onChanged?.call(); setState(() {}); }, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : null, ), ], ), ), ], ), ); } // ── 日期 Picker 卡片 ── Widget _datePickerCard({ required String label, required String value, required String hint, required AppColorsExtension colors, required ValueChanged onPick, VoidCallback? onClear, }) { final tdTheme = TDTheme.of(context); final hasValue = value.isNotEmpty; return GestureDetector( onTap: () => _pickDate(onPick), child: Container( padding: const EdgeInsets.only( left: 16, right: 10, top: 12, bottom: 12, ), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TDText( value.isEmpty ? hint : value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: value.isEmpty ? tdTheme.textColorPlaceholder : tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: onClear, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : Icon( Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder, ), ), ], ), ), ], ), ), ); } // ── 切换卡片(Y/N) ── Widget _toggleCard({ required String label, required String value, required List options, required ValueChanged onChanged, }) { final tdTheme = TDTheme.of(context); final colors = Theme.of(context).extension()!; return Container( padding: const EdgeInsets.only(left: 16, right: 10, top: 10, bottom: 10), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const Spacer(), ...options.map((opt) { final sel = value == opt; return Padding( padding: const EdgeInsets.only(left: 8), child: GestureDetector( onTap: () => onChanged(opt), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: sel ? colors.primary.withValues(alpha: 0.1) : tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all( color: sel ? colors.primary : tdTheme.componentStrokeColor, ), ), child: Text( opt == 'Y' ? '是' : '否', style: TextStyle( fontSize: 14, color: sel ? colors.primary : tdTheme.textColorPrimary, ), ), ), ), ); }), ], ), ); } // ── 只读卡片 ── Widget _readOnlyCard({required String label, required String value}) { final tdTheme = TDTheme.of(context); return Container( padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TDText( value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), ], ), ), ], ), ); } void _pickDate(ValueChanged onPick) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final now = DateTime.now(); FocusManager.instance.primaryFocus?.unfocus(); TDPicker.showDatePicker( context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard, useYear: true, useMonth: true, useDay: true, useHour: false, useMinute: false, useSecond: false, useWeekDay: false, dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31], initialDate: [now.year, now.month, now.day], onConfirm: (selected) { onPick( '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}', ); Navigator.of(context).pop(); }, ); } }