overtime_apply_edit_page.dart 33 KB

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