| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210 |
- // 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/navigation/host_app_channel.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/theme/app_colors_extension.dart';
- import '../../../shared/widgets/searchable_picker_sheet.dart';
- import '../../expense_apply/expense_apply_api.dart';
- import '../overtime_apply_api.dart';
- /// 加班明细输入数据。
- class OvertimeDetailData {
- final String? jbNo;
- final int? itm;
- final String salNo;
- final String salName;
- final String dep;
- 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 OvertimeDetailData({
- this.jbNo,
- this.itm,
- this.salNo = '',
- this.salName = '',
- this.dep = '',
- this.jbType = 'WORKING_DAY',
- this.jbDate = '',
- this.startTime = '',
- this.endTime = '',
- this.jbHours = 0.0,
- this.jbDays = 0.0,
- this.attPeriod = '',
- this.reason = '',
- this.compensationType = 'OVERTIME_PAY',
- this.compensationCount = 0.0,
- this.adr = '',
- this.rem = '',
- });
- }
- /// 添加/编辑加班明细弹窗。
- class OvertimeApplyDetailDialog extends StatefulWidget {
- final OvertimeApplyApi api;
- final AppLocalizations l10n;
- final OvertimeDetailData? initialData;
- const OvertimeApplyDetailDialog({
- super.key,
- required this.api,
- required this.l10n,
- this.initialData,
- });
- /// 显示弹窗,返回 [OvertimeDetailData] 或 `null`(取消时)。
- static Future<OvertimeDetailData?> show(
- BuildContext context, {
- required OvertimeApplyApi api,
- required AppLocalizations l10n,
- OvertimeDetailData? initialData,
- }) {
- FocusScope.of(context).unfocus();
- return Navigator.push<OvertimeDetailData>(
- context,
- TDSlidePopupRoute<OvertimeDetailData>(
- slideTransitionFrom: SlideTransitionFrom.bottom,
- isDismissible: true,
- builder: (_) => OvertimeApplyDetailDialog(
- api: api,
- l10n: l10n,
- initialData: initialData,
- ),
- ),
- );
- }
- @override
- State<OvertimeApplyDetailDialog> createState() =>
- _OvertimeApplyDetailDialogState();
- }
- class _OvertimeApplyDetailDialogState extends State<OvertimeApplyDetailDialog> {
- // 员工
- EmployeeItem? _selEmployee;
- // 部门
- String _dep = '';
- String _depName = '';
- // 加班类型
- String _jbType = 'WORKING_DAY';
- // 加班日期
- String _jbDate = '';
- // 开始/结束时间
- String _startTime = '';
- String _endTime = '';
- // 加班时长、天数
- final _hoursCtrl = TextEditingController();
- final _hoursFocus = FocusNode();
- final _daysCtrl = TextEditingController();
- final _daysFocus = FocusNode();
- // 考勤周期
- String _attPeriod = '';
- // 事由
- final _reasonCtrl = TextEditingController();
- final _reasonFocus = FocusNode();
- // 补偿类型
- String _compensationType = 'OVERTIME_PAY';
- // 折算补偿次数
- final _compCountCtrl = TextEditingController();
- final _compCountFocus = FocusNode();
- // 地点
- final _adrCtrl = TextEditingController();
- final _adrFocus = FocusNode();
- // 备注
- final _remarkCtrl = TextEditingController();
- final _remarkFocus = FocusNode();
- // 滚动
- final _scrollCtrl = ScrollController();
- // 加班类型选项
- static const _jbTypeOptions = [
- 'WORKING_DAY',
- 'REST_DAY',
- 'PUBLIC_HOLIDAY',
- 'SPECIAL_HOLIDAY',
- 'OTHER',
- ];
- // 补偿类型选项
- static const _compensationTypeOptions = [
- 'OVERTIME_PAY',
- 'COMPENSATORY_LEAVE',
- 'NO_COMPENSATION',
- 'OTHER',
- ];
- AppLocalizations get _l10n => widget.l10n;
- bool get _isEdit => widget.initialData != null;
- @override
- void initState() {
- super.initState();
- final d = widget.initialData;
- _selEmployee = (d != null && d.salNo.isNotEmpty)
- ? EmployeeItem(salNo: d.salNo, name: d.salName)
- : null;
- _dep = d?.dep ?? '';
- _depName = d?.dep ?? '';
- _jbType = d?.jbType ?? 'WORKING_DAY';
- _jbDate = d?.jbDate ?? '';
- if (!_isEdit && _jbDate.isEmpty) {
- final now = DateTime.now();
- _jbDate = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
- _attPeriod = _jbDate.substring(0, 7);
- }
- _startTime = d?.startTime ?? '';
- _endTime = d?.endTime ?? '';
- if (d != null) {
- _hoursCtrl.text = d.jbHours > 0 ? d.jbHours.toString() : '';
- _daysCtrl.text = d.jbDays > 0 ? d.jbDays.toString() : '';
- }
- _attPeriod = d?.attPeriod ?? '';
- if (d != null) {
- _reasonCtrl.text = d.reason;
- }
- _compensationType = d?.compensationType ?? 'OVERTIME_PAY';
- if (d != null) {
- _compCountCtrl.text = d.compensationCount > 0
- ? d.compensationCount.toString()
- : '';
- }
- if (d != null) {
- _adrCtrl.text = d.adr;
- _remarkCtrl.text = d.rem;
- }
- _reasonFocus.addListener(() => _ensureVisible(_reasonFocus));
- _hoursFocus.addListener(() => _ensureVisible(_hoursFocus));
- _daysFocus.addListener(() => _ensureVisible(_daysFocus));
- _compCountFocus.addListener(() => _ensureVisible(_compCountFocus));
- _adrFocus.addListener(() => _ensureVisible(_adrFocus));
- _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
- // 非编辑模式自动带出当前用户
- if (!_isEdit) {
- WidgetsBinding.instance.addPostFrameCallback((_) => _doAutoFill());
- }
- }
- void _ensureVisible(FocusNode node) {
- if (!node.hasFocus) return;
- _doEnsureVisible(node, 0, -1);
- }
- void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) {
- if (attempt >= 15) return;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
- final insets = MediaQuery.of(context).viewInsets.bottom;
- if (insets != lastInsets) {
- _doEnsureVisible(node, attempt + 1, insets);
- return;
- }
- final ctx = node.context;
- if (ctx == null) return;
- Future.delayed(const Duration(milliseconds: 500), () {
- if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
- Scrollable.ensureVisible(
- ctx,
- alignment: 0.5,
- duration: const Duration(milliseconds: 300),
- );
- });
- });
- }
- Future<void> _doAutoFill() async {
- await HostAppChannel.ensureConfig();
- final usr = HostAppChannel.usr;
- if (usr.isEmpty) return;
- try {
- final employees = await widget.api.getEmployees(salNo: usr);
- if (employees.isNotEmpty && mounted) {
- setState(() {
- _selEmployee = employees.first;
- });
- // 自动带出部门
- final depCode = HostAppChannel.dep;
- if (depCode.isNotEmpty) {
- final depts = await widget.api.getDepartments();
- final matched = depts.where((d) => d.dep == depCode);
- if (matched.isNotEmpty && mounted) {
- setState(() {
- _dep = matched.first.dep;
- _depName = matched.first.name;
- });
- }
- }
- }
- } catch (_) {
- // 静默失败
- }
- }
- @override
- void dispose() {
- _hoursCtrl.dispose();
- _hoursFocus.dispose();
- _daysCtrl.dispose();
- _daysFocus.dispose();
- _reasonCtrl.dispose();
- _reasonFocus.dispose();
- _compCountCtrl.dispose();
- _compCountFocus.dispose();
- _adrCtrl.dispose();
- _adrFocus.dispose();
- _remarkCtrl.dispose();
- _remarkFocus.dispose();
- _scrollCtrl.dispose();
- super.dispose();
- }
- void _confirm() {
- if (_selEmployee == null) {
- TDToast.showText(
- '${_l10n.get('pleaseSelect')}${_l10n.get('employee')}',
- context: context,
- );
- return;
- }
- if (_jbDate.isEmpty) {
- TDToast.showText(
- '${_l10n.get('pleaseSelect')}${_l10n.get('date')}',
- context: context,
- );
- return;
- }
- if (_startTime.isEmpty) {
- TDToast.showText(
- '${_l10n.get('pleaseSelect')}${_l10n.get('startTime')}',
- context: context,
- );
- return;
- }
- if (_endTime.isEmpty) {
- TDToast.showText(
- '${_l10n.get('pleaseSelect')}${_l10n.get('endTime')}',
- context: context,
- );
- return;
- }
- if (_startTime.compareTo(_endTime) >= 0) {
- TDToast.showText(_l10n.get('endTimeMustLater'), context: context);
- return;
- }
- final hours = double.tryParse(_hoursCtrl.text) ?? 0;
- if (hours <= 0) {
- TDToast.showText(_l10n.get('overtimeHoursPositive'), context: context);
- return;
- }
- Navigator.pop(
- context,
- OvertimeDetailData(
- jbNo: widget.initialData?.jbNo,
- itm: widget.initialData?.itm,
- salNo: _selEmployee!.salNo,
- salName: _selEmployee!.name,
- dep: _dep,
- jbType: _jbType,
- jbDate: _jbDate,
- startTime: _startTime,
- endTime: _endTime,
- jbHours: hours,
- jbDays: double.tryParse(_daysCtrl.text) ?? 0,
- attPeriod: _attPeriod,
- reason: _reasonCtrl.text.trim(),
- compensationType: _compensationType,
- compensationCount: double.tryParse(_compCountCtrl.text) ?? 0,
- adr: _adrCtrl.text,
- rem: _remarkCtrl.text,
- ),
- );
- }
- @override
- Widget build(BuildContext context) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- 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: [
- _buildEmployeePicker(colors),
- const SizedBox(height: 12),
- _buildDeptPicker(colors),
- const SizedBox(height: 12),
- _buildJbTypeSelector(colors),
- const SizedBox(height: 12),
- _buildJbDatePicker(colors),
- const SizedBox(height: 12),
- _buildStartTimePicker(colors),
- const SizedBox(height: 12),
- _buildEndTimePicker(colors),
- const SizedBox(height: 12),
- _buildHoursInput(colors),
- const SizedBox(height: 12),
- _buildDaysInput(colors),
- const SizedBox(height: 12),
- _buildAttPeriodInput(colors),
- const SizedBox(height: 12),
- _buildReasonInput(colors),
- const SizedBox(height: 12),
- _buildCompensationTypeSelector(colors),
- const SizedBox(height: 12),
- _buildCompCountInput(colors),
- const SizedBox(height: 12),
- _buildAdrInput(colors),
- const SizedBox(height: 12),
- _buildRemarkInput(colors),
- ],
- ),
- ),
- ),
- ),
- 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: _buildActions(),
- ),
- ],
- ),
- ),
- ),
- ),
- );
- }
- // ── 标题栏 ──
- 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(
- _l10n.get('addDetail'),
- 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,
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- );
- }
- // ── 通用 Picker 卡片 ──
- Widget _pickerCard({
- required String label,
- required bool required,
- required String currentLabel,
- required bool hasValue,
- required VoidCallback onTap,
- VoidCallback? onClear,
- }) {
- final tdTheme = TDTheme.of(context);
- return GestureDetector(
- onTap: () {
- FocusManager.instance.primaryFocus?.unfocus();
- onTap();
- },
- 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),
- ),
- 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: TDText(
- currentLabel,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- font: tdTheme.fontBodyLarge,
- fontWeight: FontWeight.w400,
- textColor: hasValue
- ? tdTheme.textColorPrimary
- : tdTheme.textColorPlaceholder,
- textAlign: TextAlign.end,
- ),
- ),
- const SizedBox(width: 4),
- SizedBox(
- width: 18,
- height: 18,
- child: onClear != null
- ? GestureDetector(
- onTap: onClear,
- child: Icon(
- Icons.close,
- size: 18,
- color: tdTheme.textColorPlaceholder,
- ),
- )
- : Icon(
- Icons.chevron_right,
- size: 18,
- color: tdTheme.textColorPlaceholder,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- // ── 0. 员工 ──
- Widget _buildEmployeePicker(AppColorsExtension colors) {
- return _pickerCard(
- label: _l10n.get('employee'),
- required: true,
- hasValue: _selEmployee != null,
- currentLabel: _selEmployee != null
- ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
- : _l10n.get('pleaseSelect'),
- onTap: () async {
- final result = await showSearchablePicker<EmployeeItem>(
- context,
- title: '${_l10n.get('select')}${_l10n.get('employee')}',
- searchHint: _l10n.get('search'),
- loader: (keyword, page) =>
- widget.api.getEmployees(keyword: keyword, page: page, size: 20),
- labelBuilder: (e) =>
- e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
- onRefresh: () => widget.api.clearRefCache(),
- );
- if (result != null && mounted) {
- setState(() => _selEmployee = result);
- }
- },
- onClear: _selEmployee != null
- ? () => setState(() => _selEmployee = null)
- : null,
- );
- }
- // ── 1. 部门 ──
- Widget _buildDeptPicker(AppColorsExtension colors) {
- return _pickerCard(
- label: _l10n.get('dep'),
- required: false,
- hasValue: _dep.isNotEmpty,
- currentLabel: _dep.isNotEmpty
- ? (_depName.isNotEmpty ? '$_dep/$_depName' : _dep)
- : _l10n.get('pleaseSelect'),
- onTap: () async {
- final result = await showSearchablePicker<DepartmentItem>(
- context,
- title: '${_l10n.get('select')}${_l10n.get('dep')}',
- searchHint: _l10n.get('search'),
- loader: (keyword, page) =>
- widget.api.getDepartments(keyword: keyword, page: page, size: 20),
- labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
- onRefresh: () => widget.api.clearRefCache(),
- );
- if (result != null && mounted) {
- setState(() {
- _dep = result.dep;
- _depName = result.name;
- });
- }
- },
- onClear: _dep.isNotEmpty
- ? () => setState(() {
- _dep = '';
- _depName = '';
- })
- : null,
- );
- }
- // ── 2. 加班类型 ──
- Widget _buildJbTypeSelector(AppColorsExtension colors) {
- final selLabel = _jbTypeLabel(_jbType);
- return _pickerCard(
- label: _l10n.get('jbType'),
- required: true,
- hasValue: true,
- currentLabel: selLabel,
- onTap: () => _showJbTypePicker(),
- );
- }
- void _showJbTypePicker() {
- FocusManager.instance.primaryFocus?.unfocus();
- final labels = _jbTypeOptions.map((t) => _jbTypeLabel(t)).toList();
- TDPicker.showMultiPicker(
- context,
- title: _l10n.get('selectJbType'),
- data: [labels],
- onConfirm: (selected) {
- if (selected.isNotEmpty) {
- final idx = selected.first is int ? selected.first as int : 0;
- if (idx >= 0 && idx < _jbTypeOptions.length) {
- setState(() => _jbType = _jbTypeOptions[idx]);
- }
- }
- Navigator.of(context).pop();
- },
- );
- }
- String _jbTypeLabel(String type) {
- 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;
- }
- }
- // ── 4. 申请日期 ──
- Widget _buildJbDatePicker(AppColorsExtension colors) {
- return _datePickerCard(
- label: _l10n.get('applyDate'),
- value: _jbDate,
- hint: _l10n.get('pleaseSelect'),
- colors: colors,
- required: true,
- onPick: (d) => setState(() => _jbDate = d),
- onClear: _jbDate.isNotEmpty ? () => setState(() => _jbDate = '') : null,
- );
- }
- // ── 4. 开始时间 ──
- Widget _buildStartTimePicker(AppColorsExtension colors) {
- return _datePickerCard(
- label: _l10n.get('startTime'),
- value: _startTime,
- hint: _l10n.get('pleaseSelect'),
- colors: colors,
- required: true,
- onPick: (d) {
- setState(() {
- _startTime = d;
- if (d.length >= 7) _attPeriod = d.substring(0, 7);
- _autoCalcHours();
- });
- },
- onClear: _startTime.isNotEmpty
- ? () {
- setState(() {
- _startTime = '';
- _autoCalcHours();
- });
- }
- : null,
- );
- }
- // ── 5. 结束时间 ──
- Widget _buildEndTimePicker(AppColorsExtension colors) {
- return _datePickerCard(
- label: _l10n.get('endTime'),
- value: _endTime,
- hint: _l10n.get('pleaseSelect'),
- colors: colors,
- required: true,
- onPick: (d) {
- setState(() {
- _endTime = d;
- _autoCalcHours();
- });
- },
- onClear: _endTime.isNotEmpty
- ? () {
- setState(() {
- _endTime = '';
- _autoCalcHours();
- });
- }
- : null,
- );
- }
- void _autoCalcHours() {
- if (_startTime.isEmpty || _endTime.isEmpty) return;
- try {
- final stParts = _startTime.split(' ');
- final etParts = _endTime.split(' ');
- if (stParts.length < 2 || etParts.length < 2) return;
- final stTime = stParts[1];
- final etTime = etParts[1];
- final st = _parseTime(stTime);
- final et = _parseTime(etTime);
- if (st == null || et == null) return;
- double hours = et - st;
- if (hours < 0) hours += 24.0;
- if (hours > 0) {
- // 按0.5半小时模式向上取整
- final rounded = (hours * 2).ceil() / 2.0;
- _hoursCtrl.text = rounded.toStringAsFixed(1);
- }
- _autoCalcDays();
- } catch (_) {}
- }
- void _autoCalcDays() {
- if (_startTime.isEmpty || _endTime.isEmpty) return;
- try {
- final stDateStr = _startTime.split(' ').first;
- final etDateStr = _endTime.split(' ').first;
- final stDate = DateTime.tryParse(stDateStr);
- final etDate = DateTime.tryParse(etDateStr);
- if (stDate == null || etDate == null) return;
- final diffDays = etDate.difference(stDate).inDays;
- if (diffDays < 0) return;
- // 按0.5半天模式向上取整
- final ceilDays = (diffDays * 2).ceil() / 2.0;
- _daysCtrl.text = ceilDays.toStringAsFixed(1);
- } catch (_) {}
- }
- double? _parseTime(String time) {
- final parts = time.split(':');
- if (parts.length < 2) return null;
- final h = double.tryParse(parts[0]);
- final m = double.tryParse(parts[1]);
- if (h == null || m == null) return null;
- return h + m / 60.0;
- }
- // ── 6. 加班时长 ──
- Widget _buildHoursInput(AppColorsExtension colors) {
- return _inputCard(
- label: _l10n.get('overtimeHours'),
- required: true,
- controller: _hoursCtrl,
- hintText: '>0',
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,1}$')),
- ],
- focusNode: _hoursFocus,
- );
- }
- // ── 7. 加班天数 ──
- Widget _buildDaysInput(AppColorsExtension colors) {
- return _inputCard(
- label: _l10n.get('overtimeDays'),
- required: false,
- controller: _daysCtrl,
- hintText: '0',
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
- ],
- focusNode: _daysFocus,
- );
- }
- // ── 8. 考勤周期 ──
- Widget _buildAttPeriodInput(AppColorsExtension colors) {
- return _datePickerCard(
- label: _l10n.get('attPeriod'),
- value: _attPeriod,
- hint: _l10n.get('autoFromDate'),
- colors: colors,
- onPick: (d) => setState(() => _attPeriod = d),
- onClear: _attPeriod.isNotEmpty
- ? () => setState(() => _attPeriod = '')
- : null,
- );
- }
- // ── 10. 事由 ──
- Widget _buildReasonInput(AppColorsExtension colors) {
- final tdTheme = TDTheme.of(context);
- return TDTextarea(
- controller: _reasonCtrl,
- focusNode: _reasonFocus,
- label: _l10n.get('overtimeReason'),
- hintText: _l10n.get('enterReason'),
- maxLines: 3,
- minLines: 1,
- maxLength: 1000,
- indicator: true,
- decoration: BoxDecoration(
- color: tdTheme.bgColorContainer,
- borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
- border: Border.all(color: tdTheme.componentStrokeColor),
- ),
- onChanged: (_) => setState(() {}),
- );
- }
- // ── 10. 补偿类型 ──
- Widget _buildCompensationTypeSelector(AppColorsExtension colors) {
- final selLabel = _compensationTypeLabel(_compensationType);
- return _pickerCard(
- label: _l10n.get('compensationType'),
- required: true,
- hasValue: true,
- currentLabel: selLabel,
- onTap: () => _showCompensationTypePicker(),
- );
- }
- void _showCompensationTypePicker() {
- FocusManager.instance.primaryFocus?.unfocus();
- final labels = _compensationTypeOptions
- .map((t) => _compensationTypeLabel(t))
- .toList();
- TDPicker.showMultiPicker(
- context,
- title: _l10n.get('selectCompensationType'),
- data: [labels],
- onConfirm: (selected) {
- if (selected.isNotEmpty) {
- final idx = selected.first is int ? selected.first as int : 0;
- if (idx >= 0 && idx < _compensationTypeOptions.length) {
- setState(() => _compensationType = _compensationTypeOptions[idx]);
- }
- }
- Navigator.of(context).pop();
- },
- );
- }
- String _compensationTypeLabel(String type) {
- 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;
- }
- }
- // ── 11. 折算补偿次数 ──
- Widget _buildCompCountInput(AppColorsExtension colors) {
- return _inputCard(
- label: _l10n.get('compensationCount'),
- required: false,
- controller: _compCountCtrl,
- hintText: '0',
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,1}$')),
- ],
- focusNode: _compCountFocus,
- );
- }
- // ── 12. 地点 ──
- Widget _buildAdrInput(AppColorsExtension colors) {
- return _inputCard(
- label: _l10n.get('adr'),
- required: false,
- controller: _adrCtrl,
- hintText: _l10n.get('enterAdr'),
- focusNode: _adrFocus,
- );
- }
- // ── 13. 备注 ──
- Widget _buildRemarkInput(AppColorsExtension colors) {
- final tdTheme = TDTheme.of(context);
- return TDTextarea(
- controller: _remarkCtrl,
- focusNode: _remarkFocus,
- label: _l10n.get('remark'),
- hintText: _l10n.get('enterRemark'),
- maxLines: 3,
- minLines: 1,
- maxLength: 1000,
- indicator: true,
- decoration: BoxDecoration(
- color: tdTheme.bgColorContainer,
- borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
- border: Border.all(color: tdTheme.componentStrokeColor),
- ),
- onChanged: (_) => setState(() {}),
- );
- }
- // ── 操作按钮 ──
- Widget _buildActions() {
- return 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 ? _l10n.get('confirmEdit') : _l10n.get('add'),
- size: TDButtonSize.large,
- type: TDButtonType.fill,
- shape: TDButtonShape.rectangle,
- theme: TDButtonTheme.primary,
- onTap: _confirm,
- ),
- ),
- ],
- );
- }
- // ── 日期选择器卡片 ──
- Widget _datePickerCard({
- required String label,
- required String value,
- required String hint,
- required AppColorsExtension colors,
- required ValueChanged<String> onPick,
- VoidCallback? onClear,
- bool required = false,
- }) {
- final tdTheme = TDTheme.of(context);
- final hasValue = value.isNotEmpty;
- return GestureDetector(
- onTap: () => _pickDateTime(label, 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: [
- if (required)
- Padding(
- padding: const EdgeInsets.only(right: 2),
- child: TDText(
- '*',
- font: tdTheme.fontBodyLarge,
- fontWeight: FontWeight.w400,
- style: TextStyle(color: tdTheme.errorColor6),
- ),
- ),
- 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,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- void _pickDateTime(String label, ValueChanged<String> onPick) {
- final l10n = _l10n;
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final now = DateTime.now();
- final isDateOnly =
- label == _l10n.get('applyDate') || label == _l10n.get('attPeriod');
- final isAttPeriod = label == _l10n.get('attPeriod');
- FocusManager.instance.primaryFocus?.unfocus();
- TDPicker.showDatePicker(
- context,
- title: l10n.get('selectDate'),
- backgroundColor: colors.bgCard,
- useYear: true,
- useMonth: true,
- useDay: !isAttPeriod,
- useHour: !isDateOnly,
- useMinute: !isDateOnly,
- useSecond: false,
- useWeekDay: false,
- dateStart: const [2020, 1, 1],
- dateEnd: [now.year + 1, 12, 31],
- initialDate: [now.year, now.month, now.day],
- onConfirm: (selected) {
- final year = selected['year'];
- final month = selected['month'].toString().padLeft(2, '0');
- final day = selected['day'].toString().padLeft(2, '0');
- final hour = selected['hour']?.toString().padLeft(2, '0') ?? '00';
- final minute = selected['minute']?.toString().padLeft(2, '0') ?? '00';
- if (isAttPeriod) {
- onPick('$year-$month');
- } else if (isDateOnly) {
- onPick('$year-$month-$day');
- } else {
- onPick('$year-$month-$day $hour:$minute');
- }
- Navigator.of(context).pop();
- },
- );
- }
- // ── 通用输入卡片 ──
- Widget _inputCard({
- required String label,
- required bool required,
- required TextEditingController controller,
- required String hintText,
- TextInputType? keyboardType,
- List<TextInputFormatter>? inputFormatters,
- FocusNode? focusNode,
- }) {
- 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,
- focusNode: focusNode,
- 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();
- setState(() {});
- },
- child: Icon(
- Icons.close,
- size: 18,
- color: tdTheme.textColorPlaceholder,
- ),
- )
- : null,
- ),
- ],
- ),
- ),
- ],
- ),
- );
- }
- }
|