| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388 |
- // 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 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 OvertimeDetailData({
- this.jbNo,
- this.itm,
- 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 = '',
- });
- }
- /// 添加/编辑加班明细弹窗。
- 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 = '';
- // 折算补偿次数
- 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;
- String _todayStr() {
- final now = DateTime.now();
- return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
- }
- @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?.depName ?? '';
- _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')}';
- }
- _startTime = d?.startTime ?? '';
- _endTime = d?.endTime ?? '';
- // 新增模式:开始/结束时间给默认值
- if (!_isEdit) {
- final dateStr = _jbDate.isNotEmpty ? _jbDate : _todayStr();
- final weekday = DateTime.tryParse(dateStr)?.weekday ?? DateTime.now().weekday;
- if (_startTime.isEmpty) {
- final h = (weekday <= DateTime.friday) ? 18 : 9;
- _startTime = '$dateStr ${h.toString().padLeft(2, '0')}:00';
- }
- if (_endTime.isEmpty) {
- final h = (weekday <= DateTime.friday) ? 19 : 18;
- _endTime = '$dateStr ${h.toString().padLeft(2, '0')}:00';
- }
- _attPeriod = dateStr.substring(0, 7);
- _autoCalcHours();
- }
- 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 ?? '';
- 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);
- if (!_hoursFocus.hasFocus) _roundToHalf(_hoursCtrl);
- });
- _daysFocus.addListener(() {
- _ensureVisible(_daysFocus);
- if (!_daysFocus.hasFocus) _roundToHalf(_daysCtrl);
- });
- _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,
- depName: _depName,
- 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),
- _buildJbTypeSelector(colors),
- const SizedBox(height: 12),
- _buildJbDatePicker(colors),
- const SizedBox(height: 12),
- _buildStartTimePicker(colors),
- const SizedBox(height: 12),
- _buildEndTimePicker(colors),
- // ── 非必填 ──
- const SizedBox(height: 12),
- _buildDeptPicker(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(
- _isEdit ? _l10n.get('editOvertimeDetail') : _l10n.get('addOvertimeDetail'),
- 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;
- }
- }
- String? _dayOfWeekLabel(String dateTimeStr) {
- 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;
- }
- }
- // ── 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,
- showDayOfWeek: 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,
- showDayOfWeek: true,
- onPick: (d) {
- setState(() {
- _endTime = d;
- _autoCalcHours();
- });
- },
- onClear: _endTime.isNotEmpty
- ? () {
- setState(() {
- _endTime = '';
- _autoCalcHours();
- });
- }
- : null,
- );
- }
- void _autoCalcHours() {
- if (_startTime.isEmpty || _endTime.isEmpty) return;
- final st = DateTime.tryParse(_startTime);
- final et = DateTime.tryParse(_endTime);
- if (st == null || et == null || et.isBefore(st)) return;
- // 按自然日逐天累加,每天最多 8 小时
- double totalHours = 0;
- DateTime dayStart = DateTime(st.year, st.month, st.day);
- while (dayStart.isBefore(et)) {
- final dayEnd = dayStart.add(const Duration(days: 1));
- final segStart = st.isAfter(dayStart) ? st : dayStart;
- final segEnd = et.isBefore(dayEnd) ? et : dayEnd;
- final segHours = segEnd.difference(segStart).inMinutes / 60.0;
- totalHours += segHours > 8.0 ? 8.0 : segHours;
- dayStart = dayEnd;
- }
- if (totalHours > 0) {
- _hoursCtrl.text = ((totalHours * 2).ceil() / 2.0).toStringAsFixed(1);
- }
- _autoCalcDays();
- }
- void _autoCalcDays() {
- final hours = double.tryParse(_hoursCtrl.text) ?? 0;
- if (hours <= 0) return;
- // 8小时工作制:天数 = 小时 ÷ 8
- final diffDays = hours / 8.0;
- _daysCtrl.text = ((diffDays * 2).ceil() / 2.0).toStringAsFixed(1);
- }
- /// 手动输入时失焦自动按 0.5 向上取整
- void _roundToHalf(TextEditingController controller) {
- final text = controller.text.trim();
- if (text.isEmpty) return;
- final value = double.tryParse(text);
- if (value == null || value <= 0) return;
- final rounded = (value * 2).ceil() / 2.0;
- final formatted = rounded.toStringAsFixed(1);
- if (controller.text != formatted) {
- controller.text = formatted;
- }
- }
- // ── 6. 加班时长 ──
- Widget _buildHoursInput(AppColorsExtension colors) {
- return _inputCard(
- label: _l10n.get('overtimeHours'),
- required: false,
- controller: _hoursCtrl,
- hintText: '>0',
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?[05]?$')),
- ],
- 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*\.?[05]?$')),
- ],
- 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('overtimeDetailReason'),
- 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 hasValue = _compensationType.isNotEmpty;
- final selLabel = hasValue ? _compensationTypeLabel(_compensationType) : _l10n.get('pleaseSelect');
- return _pickerCard(
- label: _l10n.get('compensationType'),
- required: false,
- hasValue: hasValue,
- 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) {
- final newType = _compensationTypeOptions[idx];
- setState(() {
- _compensationType = newType;
- // 无补偿 → 清空;其他 → 默认取 JB_DAYS
- if (newType == 'NO_COMPENSATION') {
- _compCountCtrl.clear();
- } else if (_compCountCtrl.text.isEmpty && _daysCtrl.text.isNotEmpty) {
- _compCountCtrl.text = _daysCtrl.text;
- }
- });
- }
- }
- 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) {
- // 无补偿时显示空
- if (_compensationType == 'NO_COMPENSATION') {
- final tdTheme = TDTheme.of(context);
- return GestureDetector(
- 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(
- _l10n.get('compensationCount'),
- maxLines: 1,
- overflow: TextOverflow.visible,
- font: tdTheme.fontBodyLarge,
- fontWeight: FontWeight.w400,
- style: const TextStyle(letterSpacing: 0),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: TDText(
- '',
- maxLines: 1,
- textAlign: TextAlign.end,
- font: tdTheme.fontBodyLarge,
- fontWeight: FontWeight.w400,
- textColor: tdTheme.textColorPlaceholder,
- ),
- ),
- ],
- ),
- ),
- );
- }
- 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,
- bool showDayOfWeek = false,
- }) {
- final tdTheme = TDTheme.of(context);
- final hasValue = value.isNotEmpty;
- final dayOfWeek = showDayOfWeek && hasValue ? _dayOfWeekLabel(value) : null;
- return GestureDetector(
- onTap: () => _pickDateTime(label, onPick, currentValue: value),
- 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(
- 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,
- ),
- ),
- if (dayOfWeek != null) ...[
- const SizedBox(width: 6),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 6,
- vertical: 2,
- ),
- decoration: BoxDecoration(
- color: tdTheme.brandColor1,
- borderRadius: BorderRadius.circular(4),
- ),
- child: TDText(
- dayOfWeek,
- font: TDTheme.of(context).fontBodySmall,
- fontWeight: FontWeight.w500,
- textColor: tdTheme.brandColor7,
- ),
- ),
- ],
- 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,
- {String currentValue = ''}) {
- 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');
- final isStartTime = label == _l10n.get('startTime');
- final isEndTime = label == _l10n.get('endTime');
- // 根据当前值定位初始日期,无值时默认当月/当天
- int initYear = now.year;
- int initMonth = now.month;
- int initDay = now.day;
- int initHour = now.hour;
- int initMinute = now.minute;
- if (currentValue.isNotEmpty) {
- if (isAttPeriod) {
- // yyyy-MM
- final parts = currentValue.split('-');
- if (parts.length == 2) {
- initYear = int.tryParse(parts[0]) ?? now.year;
- initMonth = int.tryParse(parts[1]) ?? now.month;
- initDay = 1;
- }
- } else {
- // yyyy-MM-dd 或 yyyy-MM-dd HH:mm
- final dt = DateTime.tryParse(currentValue);
- if (dt != null) {
- initYear = dt.year;
- initMonth = dt.month;
- initDay = dt.day;
- initHour = dt.hour;
- initMinute = dt.minute;
- }
- }
- } else if (isStartTime) {
- // 开始时间无当前值:工作日默认 18:00,周末默认 09:00
- final date = _jbDate.isNotEmpty
- ? DateTime.tryParse(_jbDate)
- : null;
- final weekday = date?.weekday ?? now.weekday;
- initHour = (weekday <= DateTime.friday) ? 18 : 9;
- initMinute = 0;
- } else if (isEndTime) {
- // 结束时间无当前值:工作日默认 19:00,周末默认 18:00
- final date = _jbDate.isNotEmpty
- ? DateTime.tryParse(_jbDate)
- : null;
- final weekday = date?.weekday ?? now.weekday;
- initHour = (weekday <= DateTime.friday) ? 19 : 18;
- initMinute = 0;
- }
- final useTime = !isDateOnly;
- FocusManager.instance.primaryFocus?.unfocus();
- TDPicker.showDatePicker(
- context,
- title: l10n.get('selectDate'),
- backgroundColor: colors.bgCard,
- useYear: true,
- useMonth: true,
- useDay: !isAttPeriod,
- useHour: useTime,
- useMinute: useTime,
- useSecond: false,
- useWeekDay: false,
- dateStart: useTime ? const [2020, 1, 1, 0, 0] : const [2020, 1, 1],
- dateEnd: useTime
- ? [now.year + 1, 12, 31, 23, 59]
- : [now.year + 1, 12, 31],
- initialDate: useTime
- ? [initYear, initMonth, initDay, initHour, initMinute]
- : [initYear, initMonth, initDay],
- 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,
- ),
- ],
- ),
- ),
- ],
- ),
- );
- }
- }
|