overtime_apply_create_page.dart 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import '../../core/i18n/app_localizations.dart';
  8. import '../../core/navigation/host_app_channel.dart';
  9. import '../../core/storage/draft_storage.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. import '../../shared/widgets/loading_dialog.dart';
  12. import '../../shared/widgets/searchable_picker_sheet.dart';
  13. import '../../shared/widgets/form_section.dart';
  14. import '../../shared/widgets/form_field_row.dart';
  15. import '../../shared/widgets/app_skeletons.dart';
  16. import '../../shared/widgets/nav_bar_config.dart';
  17. import '../../shared/helpers/audit_flow_helper.dart';
  18. import '../../core/theme/app_colors.dart';
  19. import '../../core/theme/app_colors_extension.dart';
  20. import 'overtime_apply_api.dart';
  21. import 'overtime_apply_model.dart';
  22. import 'overtime_apply_list_controller.dart';
  23. import '../expense_apply/expense_apply_api.dart';
  24. import 'widgets/overtime_apply_detail_dialog.dart';
  25. class OvertimeApplyCreatePage extends ConsumerStatefulWidget {
  26. final String? id;
  27. const OvertimeApplyCreatePage({super.key, this.id});
  28. @override
  29. ConsumerState<OvertimeApplyCreatePage> createState() =>
  30. _OvertimeApplyCreatePageState();
  31. }
  32. class _OvertimeApplyCreatePageState
  33. extends ConsumerState<OvertimeApplyCreatePage> {
  34. static const _draftKey = 'overtime_apply';
  35. // ── 基本信息 ──
  36. final _reasonController = TextEditingController();
  37. final _reasonFocus = FocusNode();
  38. final _remarkController = TextEditingController();
  39. final _remarkFocus = FocusNode();
  40. final _scrollCtrl = ScrollController();
  41. // ── 加班明细 ──
  42. final List<_DetailItem> _details = [];
  43. int _detailIdCounter = 1;
  44. // ── 草稿 ──
  45. late Future<bool> _draftFuture;
  46. bool _draftHandled = false;
  47. // ── 参考数据(从 API 加载) ──
  48. bool _firstBuild = true;
  49. bool _refDataLoading = true;
  50. bool _addingDetail = false;
  51. // ── 申请部门 ──
  52. String _selectedDeptId = '';
  53. String _selectedDeptName = '';
  54. List<DepartmentItem> _departments = [];
  55. // ── 申请人 ──
  56. String _selectedApplicantId = '';
  57. String _selectedApplicantName = '';
  58. List<EmployeeItem> _employees = [];
  59. @override
  60. void initState() {
  61. super.initState();
  62. SystemChrome.setSystemUIOverlayStyle(
  63. const SystemUiOverlayStyle(
  64. statusBarColor: Colors.transparent,
  65. statusBarIconBrightness: Brightness.dark,
  66. ),
  67. );
  68. _reasonFocus.addListener(() => _ensureVisible(_reasonFocus));
  69. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  70. _departments = [];
  71. _refDataLoading = true;
  72. _refDataFuture = null;
  73. _draftFuture = DraftStorage.has(_draftKey);
  74. _loadRefData();
  75. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  76. }
  77. void _checkDataReady() {
  78. if (!_refDataLoading && mounted) {
  79. setState(() => _firstBuild = false);
  80. WidgetsBinding.instance.addPostFrameCallback((_) {
  81. if (mounted) setState(() {});
  82. });
  83. } else if (mounted) {
  84. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  85. }
  86. }
  87. Future<void>? _refDataFuture;
  88. Future<void> _loadRefData({bool showLoading = false}) async {
  89. if (_refDataFuture != null) return _refDataFuture!;
  90. final completer = Completer<void>();
  91. _refDataFuture = completer.future;
  92. if (showLoading) {
  93. LoadingDialog.show(
  94. context,
  95. text: AppLocalizations.of(context).get('dataLoading'),
  96. );
  97. }
  98. try {
  99. final api = ref.read(overtimeApplyApiProvider);
  100. final results = await Future.wait([
  101. api.getDepartments(),
  102. api.getEmployees(),
  103. ]);
  104. if (!mounted) return;
  105. setState(() {
  106. _departments = results[0] as List<DepartmentItem>;
  107. _employees = results[1] as List<EmployeeItem>;
  108. _refDataLoading = false;
  109. _autoSelectDept();
  110. _autoSelectApplicant();
  111. });
  112. completer.complete();
  113. } catch (_) {
  114. if (!mounted) {
  115. completer.complete();
  116. return;
  117. }
  118. setState(() => _refDataLoading = false);
  119. completer.complete();
  120. } finally {
  121. if (showLoading && mounted) LoadingDialog.hide(context);
  122. _refDataFuture = null;
  123. }
  124. }
  125. void _autoSelectDept() {
  126. if (_selectedDeptId.isNotEmpty) return;
  127. final dep = HostAppChannel.dep;
  128. if (dep.isEmpty) return;
  129. final match = _departments.where((d) => d.dep == dep);
  130. if (match.isNotEmpty) {
  131. _selectedDeptId = match.first.dep;
  132. _selectedDeptName = match.first.name;
  133. }
  134. }
  135. void _autoSelectApplicant() {
  136. if (_selectedApplicantId.isNotEmpty) return;
  137. final usr = HostAppChannel.usr;
  138. if (usr.isEmpty) return;
  139. final match = _employees.where((e) => e.salNo == usr);
  140. if (match.isNotEmpty) {
  141. _selectedApplicantId = match.first.salNo;
  142. _selectedApplicantName = match.first.name;
  143. }
  144. }
  145. Future<void> _showApplicantPicker() async {
  146. FocusScope.of(context).unfocus();
  147. FocusManager.instance.primaryFocus?.unfocus();
  148. final l10n = AppLocalizations.of(context);
  149. final api = ref.read(overtimeApplyApiProvider);
  150. final result = await showSearchablePicker<EmployeeItem>(
  151. context,
  152. title: '${l10n.get('select')}${l10n.get('applicant')}',
  153. searchHint: l10n.get('search'),
  154. loader: (keyword, page) =>
  155. api.getEmployees(keyword: keyword, page: page, size: 20),
  156. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  157. );
  158. if (result != null && mounted) {
  159. setState(() {
  160. _selectedApplicantId = result.salNo;
  161. _selectedApplicantName = result.name;
  162. });
  163. }
  164. }
  165. void _ensureVisible(FocusNode node) {
  166. if (!node.hasFocus) return;
  167. WidgetsBinding.instance.addPostFrameCallback((_) {
  168. if (node.hasFocus && _scrollCtrl.hasClients) {
  169. final ctx = node.context;
  170. if (ctx != null) {
  171. Scrollable.ensureVisible(
  172. ctx,
  173. alignment: 0.3,
  174. duration: const Duration(milliseconds: 300),
  175. );
  176. }
  177. }
  178. });
  179. }
  180. @override
  181. void dispose() {
  182. _reasonController.dispose();
  183. _reasonFocus.dispose();
  184. _remarkController.dispose();
  185. _remarkFocus.dispose();
  186. _scrollCtrl.dispose();
  187. super.dispose();
  188. }
  189. @override
  190. Widget build(BuildContext context) {
  191. final l10n = AppLocalizations.of(context);
  192. if (_firstBuild) {
  193. return const SkeletonFormPage(sectionRows: [5, 3], bottomButtonCount: 2);
  194. }
  195. Future.microtask(
  196. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  197. );
  198. final pageContent = PopScope(
  199. canPop: false,
  200. onPopInvokedWithResult: (didPop, _) {
  201. if (didPop) return;
  202. _doPop();
  203. },
  204. child: Column(
  205. children: [
  206. Expanded(
  207. child: GestureDetector(
  208. onTap: () => FocusScope.of(context).unfocus(),
  209. child: SingleChildScrollView(
  210. controller: _scrollCtrl,
  211. padding: const EdgeInsets.all(16),
  212. child: Column(
  213. children: [
  214. _buildBasicInfo(l10n),
  215. const SizedBox(height: 16),
  216. _buildDetailsSection(l10n),
  217. const SizedBox(height: 24),
  218. _buildPageFooter(),
  219. ],
  220. ),
  221. ),
  222. ),
  223. ),
  224. _buildBottomBar(l10n),
  225. ],
  226. ),
  227. );
  228. return FutureBuilder<bool>(
  229. future: _draftFuture,
  230. builder: (ctx, snapshot) {
  231. final hasDraft = snapshot.hasData && snapshot.data == true;
  232. if (hasDraft && !_draftHandled) {
  233. _draftHandled = true;
  234. WidgetsBinding.instance.addPostFrameCallback((_) {
  235. if (mounted) _showDraftDialog();
  236. });
  237. }
  238. return pageContent;
  239. },
  240. );
  241. }
  242. // ═══ 草稿持久化 ═══
  243. Future<void> _restoreDraft() async {
  244. final data = await DraftStorage.load(_draftKey);
  245. if (data == null) return;
  246. try {
  247. final model = OvertimeApplyModel.fromJson(data);
  248. setState(() {
  249. _reasonController.text = model.reason;
  250. _remarkController.text = model.rem;
  251. _selectedDeptId = model.dep;
  252. _selectedDeptName = model.depName;
  253. _selectedApplicantId = model.salNo;
  254. _selectedApplicantName = model.salName;
  255. _details.clear();
  256. for (final d in model.details) {
  257. _details.add(
  258. _DetailItem(
  259. id: _detailIdCounter++,
  260. jbNo: d.jbNo,
  261. itm: d.itm,
  262. salNo: d.salNo,
  263. salName: d.salName,
  264. dep: d.dep,
  265. depName: d.depName,
  266. jbType: d.jbType,
  267. jbDate: d.jbDate != null
  268. ? '${d.jbDate!.year}-${d.jbDate!.month.toString().padLeft(2, '0')}-${d.jbDate!.day.toString().padLeft(2, '0')}'
  269. : '',
  270. startTime: d.startTime != null
  271. ? '${d.startTime!.year}-${d.startTime!.month.toString().padLeft(2, '0')}-${d.startTime!.day.toString().padLeft(2, '0')} '
  272. '${d.startTime!.hour.toString().padLeft(2, '0')}:${d.startTime!.minute.toString().padLeft(2, '0')}'
  273. : '',
  274. endTime: d.endTime != null
  275. ? '${d.endTime!.year}-${d.endTime!.month.toString().padLeft(2, '0')}-${d.endTime!.day.toString().padLeft(2, '0')} '
  276. '${d.endTime!.hour.toString().padLeft(2, '0')}:${d.endTime!.minute.toString().padLeft(2, '0')}'
  277. : '',
  278. jbHours: d.jbHours,
  279. jbDays: d.jbDays,
  280. attPeriod: d.attPeriod,
  281. reason: d.reason,
  282. compensationType: d.compensationType,
  283. compensationCount: d.compensationCount,
  284. adr: d.adr,
  285. rem: d.rem,
  286. ),
  287. );
  288. }
  289. _detailIdCounter = _details.isEmpty
  290. ? 1
  291. : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
  292. });
  293. } catch (_) {
  294. // 旧格式草稿兼容
  295. }
  296. }
  297. Future<void> _saveDraftToStorage() async {
  298. final now = DateTime.now();
  299. final model = OvertimeApplyModel(
  300. jbNo: '',
  301. jbDd: now,
  302. salNo: _selectedApplicantId,
  303. salName: _selectedApplicantName,
  304. dep: _selectedDeptId,
  305. depName: _selectedDeptName,
  306. reason: _reasonController.text,
  307. rem: _remarkController.text,
  308. usr: HostAppChannel.usr,
  309. recordDd: now,
  310. details: _details
  311. .map(
  312. (d) => OvertimeApplyDetailModel(
  313. itm: d.itm,
  314. jbNo: d.jbNo,
  315. salNo: d.salNo,
  316. salName: d.salName,
  317. dep: d.dep,
  318. depName: d.depName,
  319. jbType: d.jbType,
  320. jbDate: d.jbDate.isNotEmpty ? DateTime.tryParse(d.jbDate) : null,
  321. startTime: d.startTime.isNotEmpty
  322. ? DateTime.tryParse(d.startTime)
  323. : null,
  324. endTime: d.endTime.isNotEmpty
  325. ? DateTime.tryParse(d.endTime)
  326. : null,
  327. jbHours: d.jbHours,
  328. jbDays: d.jbDays,
  329. attPeriod: d.attPeriod,
  330. reason: d.reason,
  331. compensationType: d.compensationType,
  332. compensationCount: d.compensationCount,
  333. adr: d.adr,
  334. rem: d.rem,
  335. ),
  336. )
  337. .toList(),
  338. );
  339. await DraftStorage.save(_draftKey, model.toJson());
  340. }
  341. // ═══ 草稿弹窗 ═══
  342. void _showDraftDialog() {
  343. final l10n = AppLocalizations.of(context);
  344. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  345. FocusManager.instance.primaryFocus?.unfocus();
  346. showDialog(
  347. context: context,
  348. barrierDismissible: false,
  349. builder: (ctx) => TDAlertDialog(
  350. title: l10n.get('draftFound'),
  351. content: l10n.get('draftRestorePrompt'),
  352. buttonStyle: TDDialogButtonStyle.text,
  353. leftBtn: TDDialogButtonOptions(
  354. title: l10n.get('discard'),
  355. titleColor: colors.textSecondary,
  356. action: () {
  357. Navigator.pop(ctx);
  358. DraftStorage.delete(_draftKey);
  359. },
  360. ),
  361. rightBtn: TDDialogButtonOptions(
  362. title: l10n.get('restore'),
  363. titleColor: colors.primary,
  364. action: () {
  365. Navigator.pop(ctx);
  366. _restoreDraft();
  367. },
  368. ),
  369. ),
  370. );
  371. }
  372. // ═══ 1. 基本信息 ═══
  373. Widget _buildBasicInfo(AppLocalizations l10n) {
  374. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  375. final now = DateTime.now();
  376. final todayStr =
  377. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  378. return FormSection(
  379. title: l10n.get('basicInfo'),
  380. leadingIcon: Icons.info_outline,
  381. children: [
  382. FormFieldRow(
  383. label: l10n.get('date'),
  384. value: todayStr,
  385. readOnly: true,
  386. showArrow: false,
  387. ),
  388. const SizedBox(height: 16),
  389. FormFieldRow(
  390. label: l10n.get('dep'),
  391. value: _selectedDeptId.isNotEmpty
  392. ? '$_selectedDeptId/$_selectedDeptName'
  393. : '',
  394. hint: l10n.get('pleaseSelect'),
  395. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  396. ),
  397. const SizedBox(height: 16),
  398. FormFieldRow(
  399. label: l10n.get('applicant'),
  400. required: true,
  401. value: _selectedApplicantId.isNotEmpty
  402. ? '$_selectedApplicantId/$_selectedApplicantName'
  403. : '',
  404. hint: l10n.get('pleaseSelect'),
  405. onTap: () => _showApplicantPicker(),
  406. ),
  407. const SizedBox(height: 16),
  408. _label(l10n.get('overtimeReason'), required: true),
  409. const SizedBox(height: 8),
  410. TDTextarea(
  411. controller: _reasonController,
  412. focusNode: _reasonFocus,
  413. hintText: l10n.get('enterOvertimeReason'),
  414. maxLines: 4,
  415. minLines: 1,
  416. maxLength: 1000,
  417. indicator: true,
  418. padding: EdgeInsets.zero,
  419. bordered: true,
  420. backgroundColor: colors.bgPage,
  421. ),
  422. const SizedBox(height: 16),
  423. _label(l10n.get('remark')),
  424. const SizedBox(height: 8),
  425. TDTextarea(
  426. controller: _remarkController,
  427. focusNode: _remarkFocus,
  428. hintText: l10n.get('enterRemark'),
  429. maxLines: 3,
  430. minLines: 1,
  431. maxLength: 500,
  432. indicator: true,
  433. padding: EdgeInsets.zero,
  434. bordered: true,
  435. backgroundColor: colors.bgPage,
  436. ),
  437. ],
  438. );
  439. }
  440. // ═══ 2. 加班明细 ═══
  441. Widget _buildDetailsSection(AppLocalizations l10n) {
  442. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  443. return FormSection(
  444. title: l10n.get('overtimeDetails'),
  445. leadingIcon: Icons.access_time_outlined,
  446. showAction: true,
  447. actionText: l10n.get('add'),
  448. onActionTap: _showDetailDialog,
  449. children: [
  450. if (_details.isEmpty)
  451. Padding(
  452. padding: const EdgeInsets.symmetric(vertical: 8),
  453. child: Text(
  454. l10n.get('noDetailHint'),
  455. style: TextStyle(
  456. fontSize: AppFontSizes.subtitle,
  457. color: colors.textPlaceholder,
  458. ),
  459. ),
  460. )
  461. else
  462. ..._details.asMap().entries.map((e) {
  463. final d = e.value;
  464. return GestureDetector(
  465. onTap: () => _showDetailDialog(editIndex: e.key),
  466. child: Container(
  467. margin: const EdgeInsets.symmetric(vertical: 6),
  468. padding: const EdgeInsets.all(12),
  469. decoration: BoxDecoration(
  470. color: colors.bgPage,
  471. borderRadius: BorderRadius.circular(8),
  472. ),
  473. child: Row(
  474. children: [
  475. Expanded(
  476. child: Column(
  477. crossAxisAlignment: CrossAxisAlignment.start,
  478. children: [
  479. Row(
  480. children: [
  481. Expanded(
  482. child: Text(
  483. '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}',
  484. maxLines: 1,
  485. overflow: TextOverflow.ellipsis,
  486. style: TextStyle(
  487. fontSize: AppFontSizes.body,
  488. fontWeight: FontWeight.w500,
  489. color: colors.textPrimary,
  490. ),
  491. ),
  492. ),
  493. Text(
  494. '${d.jbHours.toStringAsFixed(1)}${l10n.get('hours')}',
  495. style: TextStyle(
  496. fontSize: AppFontSizes.body,
  497. fontWeight: FontWeight.w600,
  498. color: colors.timePrimary,
  499. ),
  500. ),
  501. ],
  502. ),
  503. if (d.jbType.isNotEmpty) ...[
  504. const SizedBox(height: 2),
  505. _detailLabel(
  506. '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}',
  507. colors,
  508. ),
  509. _detailLabel(
  510. '${l10n.get('jbDate')}: ${d.jbDate}',
  511. colors,
  512. trailing: _weekdayLabel(d.jbDate, l10n) != null
  513. ? _weekdayTag(_weekdayLabel(d.jbDate, l10n)!)
  514. : null,
  515. ),
  516. ],
  517. if (d.startTime.isNotEmpty) ...[
  518. const SizedBox(height: 2),
  519. _detailLabel(
  520. '${l10n.get('startTime')}: ${_fmtTime(d.startTime)}',
  521. colors,
  522. ),
  523. ],
  524. if (d.endTime.isNotEmpty) ...[
  525. const SizedBox(height: 2),
  526. _detailLabel(
  527. '${l10n.get('endTime')}: ${_fmtTime(d.endTime)}',
  528. colors,
  529. ),
  530. ],
  531. // TODO: 加班天数暂时隐藏
  532. // if (d.jbDays > 0) ...[
  533. // const SizedBox(height: 2),
  534. // _detailLabel(
  535. // '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}',
  536. // colors,
  537. // ),
  538. // ],
  539. if (d.attPeriod.isNotEmpty) ...[
  540. const SizedBox(height: 2),
  541. _detailLabel(
  542. '${l10n.get('attPeriod')}: ${d.attPeriod}',
  543. colors,
  544. ),
  545. ],
  546. // TODO: 补偿类型、补偿次数暂时隐藏
  547. // if (d.compensationType.isNotEmpty) ...[
  548. // const SizedBox(height: 2),
  549. // _detailLabel(
  550. // '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}',
  551. // colors,
  552. // ),
  553. // if (d.compensationType != 'NO_COMPENSATION' &&
  554. // d.compensationCount > 0)
  555. // _detailLabel(
  556. // '${l10n.get('compensationCount')}: ${d.compensationCount.toStringAsFixed(1)}',
  557. // colors,
  558. // ),
  559. // ],
  560. if (d.adr.isNotEmpty) ...[
  561. const SizedBox(height: 2),
  562. _detailLabel(
  563. '${l10n.get('adr')}: ${d.adr}',
  564. colors,
  565. ),
  566. ],
  567. if (d.reason.isNotEmpty) ...[
  568. const SizedBox(height: 2),
  569. _detailLabel(
  570. '${l10n.get('overtimeDetailReason')}: ${d.reason}',
  571. colors,
  572. ),
  573. ],
  574. if (d.rem.isNotEmpty) ...[
  575. const SizedBox(height: 2),
  576. _detailLabel(
  577. '${l10n.get('remark')}: ${d.rem}',
  578. colors,
  579. ),
  580. ],
  581. ],
  582. ),
  583. ),
  584. const SizedBox(width: 8),
  585. GestureDetector(
  586. onTap: () => setState(() => _details.removeAt(e.key)),
  587. child: Icon(
  588. Icons.close,
  589. size: 18,
  590. color: colors.textSecondary,
  591. ),
  592. ),
  593. ],
  594. ),
  595. ),
  596. );
  597. }),
  598. const SizedBox(height: 8),
  599. Container(
  600. padding: const EdgeInsets.symmetric(vertical: 8),
  601. child: Row(
  602. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  603. children: [
  604. Text(
  605. l10n.get('totalOvertimeHours'),
  606. style: TextStyle(
  607. fontSize: AppFontSizes.body,
  608. fontWeight: FontWeight.w600,
  609. color: colors.textPrimary,
  610. ),
  611. ),
  612. Text(
  613. '${_totalHours().toStringAsFixed(1)}${l10n.get('hours')}',
  614. style: TextStyle(
  615. fontSize: AppFontSizes.subtitle,
  616. fontWeight: FontWeight.w700,
  617. color: colors.timePrimary,
  618. ),
  619. ),
  620. ],
  621. ),
  622. ),
  623. ],
  624. );
  625. }
  626. double _totalHours() => _details.fold(0.0, (s, d) => s + d.jbHours);
  627. String _jbTypeLabel(String type, AppLocalizations l10n) {
  628. switch (type) {
  629. case 'WORKING_DAY':
  630. return l10n.get('workingDay');
  631. case 'REST_DAY':
  632. return l10n.get('restDay');
  633. case 'PUBLIC_HOLIDAY':
  634. return l10n.get('publicHoliday');
  635. case 'SPECIAL_HOLIDAY':
  636. return l10n.get('specialHoliday');
  637. case 'OTHER':
  638. return l10n.get('other');
  639. default:
  640. return type;
  641. }
  642. }
  643. String _fmtTime(String dt) {
  644. if (dt.length >= 16) return dt.substring(11, 16);
  645. return dt;
  646. }
  647. String? _weekdayLabel(String dateTimeStr, AppLocalizations l10n) {
  648. final dt = DateTime.tryParse(dateTimeStr);
  649. if (dt == null) return null;
  650. switch (dt.weekday) {
  651. case 1:
  652. return l10n.get('monday');
  653. case 2:
  654. return l10n.get('tuesday');
  655. case 3:
  656. return l10n.get('wednesday');
  657. case 4:
  658. return l10n.get('thursday');
  659. case 5:
  660. return l10n.get('friday');
  661. case 6:
  662. return l10n.get('saturday');
  663. case 7:
  664. return l10n.get('sunday');
  665. default:
  666. return null;
  667. }
  668. }
  669. String _compensationTypeLabel(String type, AppLocalizations l10n) {
  670. switch (type) {
  671. case 'OVERTIME_PAY':
  672. return l10n.get('overtimePay');
  673. case 'COMPENSATORY_LEAVE':
  674. return l10n.get('compensatoryLeave');
  675. case 'NO_COMPENSATION':
  676. return l10n.get('noCompensation');
  677. case 'OTHER':
  678. return l10n.get('other');
  679. default:
  680. return type;
  681. }
  682. }
  683. Widget _detailLabel(
  684. String text,
  685. AppColorsExtension colors, {
  686. Widget? trailing,
  687. }) {
  688. return Padding(
  689. padding: const EdgeInsets.only(top: 2),
  690. child: Row(
  691. children: [
  692. Flexible(
  693. child: Text(
  694. text,
  695. maxLines: 2,
  696. overflow: TextOverflow.ellipsis,
  697. style: TextStyle(
  698. fontSize: AppFontSizes.caption,
  699. color: colors.textSecondary,
  700. ),
  701. ),
  702. ),
  703. if (trailing != null) ...[const SizedBox(width: 6), trailing],
  704. ],
  705. ),
  706. );
  707. }
  708. Widget _weekdayTag(String label) {
  709. final tdTheme = TDTheme.of(context);
  710. return Container(
  711. padding: const EdgeInsets.symmetric(horizontal: 6),
  712. decoration: BoxDecoration(
  713. color: tdTheme.brandColor1,
  714. borderRadius: BorderRadius.circular(4),
  715. ),
  716. child: TDText(
  717. label,
  718. font: tdTheme.fontBodySmall,
  719. fontWeight: FontWeight.w500,
  720. textColor: tdTheme.brandColor7,
  721. ),
  722. );
  723. }
  724. Future<void> _showDetailDialog({int? editIndex}) async {
  725. if (_addingDetail) return;
  726. _addingDetail = true;
  727. try {
  728. final l10n = AppLocalizations.of(context);
  729. OvertimeDetailData? initialData;
  730. if (editIndex != null) {
  731. final d = _details[editIndex];
  732. initialData = OvertimeDetailData(
  733. jbNo: d.jbNo,
  734. itm: d.itm > 0 ? d.itm : null,
  735. salNo: d.salNo,
  736. salName: d.salName,
  737. dep: d.dep,
  738. depName: d.depName,
  739. jbType: d.jbType,
  740. jbDate: d.jbDate,
  741. startTime: d.startTime,
  742. endTime: d.endTime,
  743. jbHours: d.jbHours,
  744. jbDays: d.jbDays,
  745. attPeriod: d.attPeriod,
  746. reason: d.reason,
  747. compensationType: d.compensationType,
  748. compensationCount: d.compensationCount,
  749. adr: d.adr,
  750. rem: d.rem,
  751. );
  752. }
  753. FocusManager.instance.primaryFocus?.unfocus();
  754. final result = await OvertimeApplyDetailDialog.show(
  755. // ignore: use_build_context_synchronously
  756. context,
  757. api: ref.read(overtimeApplyApiProvider),
  758. l10n: l10n,
  759. initialData: initialData,
  760. );
  761. if (result != null && mounted) {
  762. setState(() {
  763. final item = _DetailItem(
  764. id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
  765. jbNo: result.jbNo,
  766. itm: result.itm ?? 0,
  767. salNo: result.salNo,
  768. salName: result.salName,
  769. dep: result.dep,
  770. depName: result.depName,
  771. jbType: result.jbType,
  772. jbDate: result.jbDate,
  773. startTime: result.startTime,
  774. endTime: result.endTime,
  775. jbHours: result.jbHours,
  776. jbDays: result.jbDays,
  777. attPeriod: result.attPeriod,
  778. reason: result.reason,
  779. compensationType: result.compensationType,
  780. compensationCount: result.compensationCount,
  781. adr: result.adr,
  782. rem: result.rem,
  783. );
  784. if (editIndex != null) {
  785. _details[editIndex] = item;
  786. } else {
  787. _details.add(item);
  788. }
  789. });
  790. }
  791. } finally {
  792. _addingDetail = false;
  793. }
  794. }
  795. // ═══ 3. 底部操作栏 ═══
  796. Widget _buildBottomBar(AppLocalizations l10n) {
  797. return ActionBar(
  798. showLeft: false,
  799. centerLabel: l10n.get('saveDraft'),
  800. rightLabel: l10n.get('submit'),
  801. centerTextOnly: true,
  802. onCenterTap: () async {
  803. FocusScope.of(context).unfocus();
  804. try {
  805. await _saveDraftToStorage();
  806. if (mounted) _forcePop();
  807. } catch (_) {
  808. if (mounted) {
  809. TDToast.showFail(l10n.get('saveFailed'), context: context);
  810. }
  811. }
  812. },
  813. onRightTap: () async {
  814. // 点提交先收键盘清焦点,避免输入框残留焦点导致键盘自动弹出
  815. FocusManager.instance.primaryFocus?.unfocus();
  816. FocusScope.of(context).unfocus();
  817. final err = _validate(l10n);
  818. if (err.isNotEmpty) {
  819. TDToast.showText(err.first, context: context);
  820. return;
  821. }
  822. LoadingDialog.show(context, text: l10n.get('submitting'));
  823. try {
  824. final data = _buildSubmitData();
  825. final api = ref.read(overtimeApplyApiProvider);
  826. final billNo = await api.submit(data);
  827. await DraftStorage.delete(_draftKey);
  828. if (!mounted) return;
  829. LoadingDialog.hide(context);
  830. if (billNo != null) {
  831. final dd = data['HeadData']['JB_DD']?.toString() ?? '';
  832. await AuditFlowHelper.handle(
  833. context: context,
  834. l10n: l10n,
  835. getConfig: () => api.getBillAuditConfig('JB'),
  836. onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd),
  837. );
  838. }
  839. if (mounted) {
  840. TDToast.showSuccess(l10n.get('billCreated'), context: context);
  841. ref.read(overtimeApplyRefreshProvider.notifier).state++;
  842. GoRouter.of(context).go('/overtime-apply/list');
  843. }
  844. } catch (e) {
  845. if (mounted) LoadingDialog.hide(context);
  846. }
  847. },
  848. );
  849. }
  850. Map<String, dynamic> _buildSubmitData() {
  851. final now = DateTime.now();
  852. final jbDd =
  853. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  854. return {
  855. 'HeadData': {
  856. 'JB_DD': jbDd,
  857. 'SAL_NO': _selectedApplicantId.isNotEmpty
  858. ? _selectedApplicantId
  859. : HostAppChannel.usr,
  860. 'DEP': _selectedDeptId,
  861. 'REASON': _reasonController.text.trim(),
  862. 'REM': _remarkController.text,
  863. 'USR': HostAppChannel.usr,
  864. },
  865. 'BodyData1': _details.asMap().entries.map((e) {
  866. final d = e.value;
  867. return {
  868. 'ITM': e.key + 1,
  869. 'SAL_NO': d.salNo,
  870. 'DEP': d.dep.isNotEmpty ? d.dep : _selectedDeptId,
  871. 'JB_TYPE': d.jbType,
  872. 'JB_DATE': d.jbDate,
  873. 'START_TIME': d.startTime,
  874. 'END_TIME': d.endTime,
  875. 'JB_HOURS': d.jbHours,
  876. 'JB_DAYS': d.jbDays,
  877. 'ATT_PERIOD': d.attPeriod,
  878. 'REASON': d.reason,
  879. 'COMPENSATION_TYPE': d.compensationType,
  880. 'COMPENSATION_COUNT': d.compensationCount,
  881. 'ADR': d.adr,
  882. 'REM': d.rem,
  883. };
  884. }).toList(),
  885. };
  886. }
  887. List<String> _validate(AppLocalizations l10n) {
  888. final e = <String>[];
  889. if (_reasonController.text.trim().isEmpty) {
  890. e.add(l10n.get('enterOvertimeReason'));
  891. }
  892. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneOTDetail'));
  893. if (_selectedDeptId.isEmpty) e.add(l10n.get('selectDept'));
  894. if (_selectedApplicantId.isEmpty) e.add(l10n.get('selectApplicant'));
  895. return e;
  896. }
  897. void _doPop() {
  898. if (_hasUnsaved()) {
  899. final l10n = AppLocalizations.of(context);
  900. _showConfirmDialog(
  901. l10n.get('confirmExit'),
  902. l10n.get('unsavedContentWarning'),
  903. l10n.get('continueEditing'),
  904. l10n.get('discardAndExit'),
  905. () async {
  906. await DraftStorage.delete(_draftKey);
  907. if (!mounted) return;
  908. setState(() => _clearLocalState());
  909. _forcePop();
  910. },
  911. );
  912. } else {
  913. _forcePop();
  914. }
  915. }
  916. void _forcePop() {
  917. FocusManager.instance.primaryFocus?.unfocus();
  918. final router = GoRouter.of(context);
  919. if (router.canPop()) {
  920. router.pop();
  921. } else {
  922. SystemNavigator.pop();
  923. }
  924. }
  925. bool _hasUnsaved() =>
  926. _reasonController.text.isNotEmpty ||
  927. _details.isNotEmpty ||
  928. _remarkController.text.isNotEmpty;
  929. void _clearLocalState() {
  930. _reasonController.clear();
  931. _remarkController.clear();
  932. _details.clear();
  933. _detailIdCounter = 1;
  934. _selectedDeptId = '';
  935. _selectedDeptName = '';
  936. _selectedApplicantId = '';
  937. _selectedApplicantName = '';
  938. }
  939. void _unfocus() => FocusScope.of(context).unfocus();
  940. void _showConfirmDialog(
  941. String title,
  942. String content,
  943. String leftText,
  944. String rightText,
  945. VoidCallback onConfirm,
  946. ) {
  947. _unfocus();
  948. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  949. showDialog(
  950. context: context,
  951. useRootNavigator: true,
  952. builder: (ctx) => TDAlertDialog(
  953. title: title,
  954. content: content,
  955. buttonStyle: TDDialogButtonStyle.text,
  956. leftBtn: TDDialogButtonOptions(
  957. title: leftText,
  958. titleColor: colors.primary,
  959. action: () => Navigator.pop(ctx),
  960. ),
  961. rightBtn: TDDialogButtonOptions(
  962. title: rightText,
  963. titleColor: colors.danger,
  964. action: () {
  965. Navigator.pop(ctx);
  966. onConfirm();
  967. },
  968. ),
  969. ),
  970. );
  971. }
  972. Widget _label(String t, {bool required = false}) {
  973. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  974. return Text.rich(
  975. TextSpan(
  976. children: [
  977. TextSpan(
  978. text: t,
  979. style: TextStyle(
  980. fontSize: AppFontSizes.subtitle,
  981. color: colors.textSecondary,
  982. ),
  983. ),
  984. if (required)
  985. TextSpan(
  986. text: ' *',
  987. style: TextStyle(
  988. fontSize: AppFontSizes.subtitle,
  989. color: colors.danger,
  990. ),
  991. ),
  992. ],
  993. ),
  994. );
  995. }
  996. Widget _buildPageFooter() {
  997. final l10n = AppLocalizations.of(context);
  998. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  999. return Center(
  1000. child: Padding(
  1001. padding: const EdgeInsets.only(bottom: 16),
  1002. child: Row(
  1003. mainAxisSize: MainAxisSize.min,
  1004. children: [
  1005. Icon(
  1006. Icons.rocket_launch_outlined,
  1007. size: 16,
  1008. color: colors.textPlaceholder,
  1009. ),
  1010. const SizedBox(width: 6),
  1011. Text(
  1012. l10n.get('pageFooter'),
  1013. style: TextStyle(
  1014. fontSize: AppFontSizes.caption,
  1015. color: colors.textPlaceholder,
  1016. ),
  1017. ),
  1018. ],
  1019. ),
  1020. ),
  1021. );
  1022. }
  1023. Future<void> _showDeptPicker() async {
  1024. FocusManager.instance.primaryFocus?.unfocus();
  1025. final l10n = AppLocalizations.of(context);
  1026. final api = ref.read(overtimeApplyApiProvider);
  1027. final result = await showSearchablePicker<DepartmentItem>(
  1028. context,
  1029. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  1030. searchHint: l10n.get('search'),
  1031. loader: (keyword, page) =>
  1032. api.getDepartments(keyword: keyword, page: page, size: 20),
  1033. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  1034. onRefresh: () => api.clearRefCache(),
  1035. );
  1036. if (result != null && mounted) {
  1037. setState(() {
  1038. _selectedDeptId = result.dep;
  1039. _selectedDeptName = result.name;
  1040. });
  1041. }
  1042. }
  1043. }
  1044. class _DetailItem {
  1045. final int id;
  1046. final String? jbNo;
  1047. final int itm;
  1048. final String salNo;
  1049. final String salName;
  1050. final String dep;
  1051. final String depName;
  1052. final String jbType;
  1053. final String jbDate;
  1054. final String startTime;
  1055. final String endTime;
  1056. final double jbHours;
  1057. final double jbDays;
  1058. final String attPeriod;
  1059. final String reason;
  1060. final String compensationType;
  1061. final double compensationCount;
  1062. final String adr;
  1063. final String rem;
  1064. const _DetailItem({
  1065. required this.id,
  1066. this.jbNo,
  1067. this.itm = 0,
  1068. this.salNo = '',
  1069. this.salName = '',
  1070. this.dep = '',
  1071. this.depName = '',
  1072. this.jbType = 'WORKING_DAY',
  1073. this.jbDate = '',
  1074. this.startTime = '',
  1075. this.endTime = '',
  1076. this.jbHours = 0.0,
  1077. this.jbDays = 0.0,
  1078. this.attPeriod = '',
  1079. this.reason = '',
  1080. this.compensationType = '',
  1081. this.compensationCount = 0.0,
  1082. this.adr = '',
  1083. this.rem = '',
  1084. });
  1085. }