overtime_apply_create_page.dart 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  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
  321. ? DateTime.tryParse(d.jbDate)
  322. : null,
  323. startTime: d.startTime.isNotEmpty
  324. ? DateTime.tryParse(d.startTime)
  325. : null,
  326. endTime: d.endTime.isNotEmpty
  327. ? DateTime.tryParse(d.endTime)
  328. : null,
  329. jbHours: d.jbHours,
  330. jbDays: d.jbDays,
  331. attPeriod: d.attPeriod,
  332. reason: d.reason,
  333. compensationType: d.compensationType,
  334. compensationCount: d.compensationCount,
  335. adr: d.adr,
  336. rem: d.rem,
  337. ),
  338. )
  339. .toList(),
  340. );
  341. await DraftStorage.save(_draftKey, model.toJson());
  342. }
  343. // ═══ 草稿弹窗 ═══
  344. void _showDraftDialog() {
  345. final l10n = AppLocalizations.of(context);
  346. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  347. FocusManager.instance.primaryFocus?.unfocus();
  348. showDialog(
  349. context: context,
  350. barrierDismissible: false,
  351. builder: (ctx) => TDAlertDialog(
  352. title: l10n.get('draftFound'),
  353. content: l10n.get('draftRestorePrompt'),
  354. buttonStyle: TDDialogButtonStyle.text,
  355. leftBtn: TDDialogButtonOptions(
  356. title: l10n.get('discard'),
  357. titleColor: colors.textSecondary,
  358. action: () {
  359. Navigator.pop(ctx);
  360. DraftStorage.delete(_draftKey);
  361. },
  362. ),
  363. rightBtn: TDDialogButtonOptions(
  364. title: l10n.get('restore'),
  365. titleColor: colors.primary,
  366. action: () {
  367. Navigator.pop(ctx);
  368. _restoreDraft();
  369. },
  370. ),
  371. ),
  372. );
  373. }
  374. // ═══ 1. 基本信息 ═══
  375. Widget _buildBasicInfo(AppLocalizations l10n) {
  376. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  377. final now = DateTime.now();
  378. final todayStr =
  379. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  380. return FormSection(
  381. title: l10n.get('basicInfo'),
  382. leadingIcon: Icons.info_outline,
  383. children: [
  384. FormFieldRow(
  385. label: l10n.get('date'),
  386. value: todayStr,
  387. readOnly: true,
  388. showArrow: false,
  389. ),
  390. const SizedBox(height: 16),
  391. FormFieldRow(
  392. label: l10n.get('dep'),
  393. value: _selectedDeptId.isNotEmpty
  394. ? '$_selectedDeptId/$_selectedDeptName'
  395. : '',
  396. hint: l10n.get('pleaseSelect'),
  397. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  398. ),
  399. const SizedBox(height: 16),
  400. FormFieldRow(
  401. label: l10n.get('applicant'),
  402. required: true,
  403. value: _selectedApplicantId.isNotEmpty
  404. ? '$_selectedApplicantId/$_selectedApplicantName'
  405. : '',
  406. hint: l10n.get('pleaseSelect'),
  407. onTap: () => _showApplicantPicker(),
  408. ),
  409. const SizedBox(height: 16),
  410. _label(l10n.get('overtimeReason'), required: true),
  411. const SizedBox(height: 8),
  412. TDTextarea(
  413. controller: _reasonController,
  414. focusNode: _reasonFocus,
  415. hintText: l10n.get('enterOvertimeReason'),
  416. maxLines: 4,
  417. minLines: 1,
  418. maxLength: 1000,
  419. indicator: true,
  420. padding: EdgeInsets.zero,
  421. bordered: true,
  422. backgroundColor: colors.bgPage,
  423. ),
  424. const SizedBox(height: 16),
  425. _label(l10n.get('remark')),
  426. const SizedBox(height: 8),
  427. TDTextarea(
  428. controller: _remarkController,
  429. focusNode: _remarkFocus,
  430. hintText: l10n.get('enterRemark'),
  431. maxLines: 3,
  432. minLines: 1,
  433. maxLength: 500,
  434. indicator: true,
  435. padding: EdgeInsets.zero,
  436. bordered: true,
  437. backgroundColor: colors.bgPage,
  438. ),
  439. ],
  440. );
  441. }
  442. // ═══ 2. 加班明细 ═══
  443. Widget _buildDetailsSection(AppLocalizations l10n) {
  444. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  445. return FormSection(
  446. title: l10n.get('overtimeDetails'),
  447. leadingIcon: Icons.access_time_outlined,
  448. showAction: true,
  449. actionText: l10n.get('add'),
  450. onActionTap: _showDetailDialog,
  451. children: [
  452. if (_details.isEmpty)
  453. Padding(
  454. padding: const EdgeInsets.symmetric(vertical: 8),
  455. child: Text(
  456. l10n.get('noDetailHint'),
  457. style: TextStyle(
  458. fontSize: AppFontSizes.subtitle,
  459. color: colors.textPlaceholder,
  460. ),
  461. ),
  462. )
  463. else
  464. ..._details.asMap().entries.map((e) {
  465. final d = e.value;
  466. return GestureDetector(
  467. onTap: () => _showDetailDialog(editIndex: e.key),
  468. child: Container(
  469. margin: const EdgeInsets.symmetric(vertical: 6),
  470. padding: const EdgeInsets.all(12),
  471. decoration: BoxDecoration(
  472. color: colors.bgPage,
  473. borderRadius: BorderRadius.circular(8),
  474. ),
  475. child: Row(
  476. children: [
  477. Expanded(
  478. child: Column(
  479. crossAxisAlignment: CrossAxisAlignment.start,
  480. children: [
  481. Row(
  482. children: [
  483. Expanded(
  484. child: Text(
  485. '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}',
  486. maxLines: 1,
  487. overflow: TextOverflow.ellipsis,
  488. style: TextStyle(
  489. fontSize: AppFontSizes.body,
  490. fontWeight: FontWeight.w500,
  491. color: colors.textPrimary,
  492. ),
  493. ),
  494. ),
  495. Text(
  496. '${d.jbHours.toStringAsFixed(1)}${l10n.get('hours')}',
  497. style: TextStyle(
  498. fontSize: AppFontSizes.body,
  499. fontWeight: FontWeight.w600,
  500. color: colors.timePrimary,
  501. ),
  502. ),
  503. ],
  504. ),
  505. if (d.jbType.isNotEmpty) ...[
  506. const SizedBox(height: 2),
  507. _detailLabel(
  508. '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}',
  509. colors,
  510. ),
  511. _detailLabel(
  512. '${l10n.get('jbDate')}: ${d.jbDate}',
  513. colors,
  514. trailing: _weekdayLabel(d.jbDate, l10n) != null
  515. ? _weekdayTag(
  516. _weekdayLabel(d.jbDate, l10n)!,
  517. )
  518. : null,
  519. ),
  520. ],
  521. if (d.startTime.isNotEmpty) ...[
  522. const SizedBox(height: 2),
  523. _detailLabel(
  524. '${l10n.get('startTime')}: ${_fmtTime(d.startTime)}',
  525. colors,
  526. ),
  527. ],
  528. if (d.endTime.isNotEmpty) ...[
  529. const SizedBox(height: 2),
  530. _detailLabel(
  531. '${l10n.get('endTime')}: ${_fmtTime(d.endTime)}',
  532. colors,
  533. ),
  534. ],
  535. // TODO: 加班天数暂时隐藏
  536. // if (d.jbDays > 0) ...[
  537. // const SizedBox(height: 2),
  538. // _detailLabel(
  539. // '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}',
  540. // colors,
  541. // ),
  542. // ],
  543. if (d.attPeriod.isNotEmpty) ...[
  544. const SizedBox(height: 2),
  545. _detailLabel(
  546. '${l10n.get('attPeriod')}: ${d.attPeriod}',
  547. colors,
  548. ),
  549. ],
  550. // TODO: 补偿类型、补偿次数暂时隐藏
  551. // if (d.compensationType.isNotEmpty) ...[
  552. // const SizedBox(height: 2),
  553. // _detailLabel(
  554. // '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}',
  555. // colors,
  556. // ),
  557. // if (d.compensationType != 'NO_COMPENSATION' &&
  558. // d.compensationCount > 0)
  559. // _detailLabel(
  560. // '${l10n.get('compensationCount')}: ${d.compensationCount.toStringAsFixed(1)}',
  561. // colors,
  562. // ),
  563. // ],
  564. if (d.adr.isNotEmpty) ...[
  565. const SizedBox(height: 2),
  566. _detailLabel(
  567. '${l10n.get('adr')}: ${d.adr}',
  568. colors,
  569. ),
  570. ],
  571. if (d.reason.isNotEmpty) ...[
  572. const SizedBox(height: 2),
  573. _detailLabel(
  574. '${l10n.get('overtimeDetailReason')}: ${d.reason}',
  575. colors,
  576. ),
  577. ],
  578. if (d.rem.isNotEmpty) ...[
  579. const SizedBox(height: 2),
  580. _detailLabel(
  581. '${l10n.get('remark')}: ${d.rem}',
  582. colors,
  583. ),
  584. ],
  585. ],
  586. ),
  587. ),
  588. const SizedBox(width: 8),
  589. GestureDetector(
  590. onTap: () => setState(() => _details.removeAt(e.key)),
  591. child: Icon(
  592. Icons.close,
  593. size: 18,
  594. color: colors.textSecondary,
  595. ),
  596. ),
  597. ],
  598. ),
  599. ),
  600. );
  601. }),
  602. const SizedBox(height: 8),
  603. Container(
  604. padding: const EdgeInsets.symmetric(vertical: 8),
  605. child: Row(
  606. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  607. children: [
  608. Text(
  609. l10n.get('totalOvertimeHours'),
  610. style: TextStyle(
  611. fontSize: AppFontSizes.body,
  612. fontWeight: FontWeight.w600,
  613. color: colors.textPrimary,
  614. ),
  615. ),
  616. Text(
  617. '${_totalHours().toStringAsFixed(1)}${l10n.get('hours')}',
  618. style: TextStyle(
  619. fontSize: AppFontSizes.subtitle,
  620. fontWeight: FontWeight.w700,
  621. color: colors.timePrimary,
  622. ),
  623. ),
  624. ],
  625. ),
  626. ),
  627. ],
  628. );
  629. }
  630. double _totalHours() => _details.fold(0.0, (s, d) => s + d.jbHours);
  631. String _jbTypeLabel(String type, AppLocalizations l10n) {
  632. switch (type) {
  633. case 'WORKING_DAY':
  634. return l10n.get('workingDay');
  635. case 'REST_DAY':
  636. return l10n.get('restDay');
  637. case 'PUBLIC_HOLIDAY':
  638. return l10n.get('publicHoliday');
  639. case 'SPECIAL_HOLIDAY':
  640. return l10n.get('specialHoliday');
  641. case 'OTHER':
  642. return l10n.get('other');
  643. default:
  644. return type;
  645. }
  646. }
  647. String _fmtTime(String dt) {
  648. if (dt.length >= 16) return dt.substring(11, 16);
  649. return dt;
  650. }
  651. String? _weekdayLabel(String dateTimeStr, AppLocalizations l10n) {
  652. final dt = DateTime.tryParse(dateTimeStr);
  653. if (dt == null) return null;
  654. switch (dt.weekday) {
  655. case 1:
  656. return l10n.get('monday');
  657. case 2:
  658. return l10n.get('tuesday');
  659. case 3:
  660. return l10n.get('wednesday');
  661. case 4:
  662. return l10n.get('thursday');
  663. case 5:
  664. return l10n.get('friday');
  665. case 6:
  666. return l10n.get('saturday');
  667. case 7:
  668. return l10n.get('sunday');
  669. default:
  670. return null;
  671. }
  672. }
  673. String _compensationTypeLabel(String type, AppLocalizations l10n) {
  674. switch (type) {
  675. case 'OVERTIME_PAY':
  676. return l10n.get('overtimePay');
  677. case 'COMPENSATORY_LEAVE':
  678. return l10n.get('compensatoryLeave');
  679. case 'NO_COMPENSATION':
  680. return l10n.get('noCompensation');
  681. case 'OTHER':
  682. return l10n.get('other');
  683. default:
  684. return type;
  685. }
  686. }
  687. Widget _detailLabel(String text, AppColorsExtension colors, {Widget? trailing}) {
  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) ...[
  704. const SizedBox(width: 6),
  705. trailing,
  706. ],
  707. ],
  708. ),
  709. );
  710. }
  711. Widget _weekdayTag(String label) {
  712. final tdTheme = TDTheme.of(context);
  713. return Container(
  714. padding: const EdgeInsets.symmetric(horizontal: 6),
  715. decoration: BoxDecoration(
  716. color: tdTheme.brandColor1,
  717. borderRadius: BorderRadius.circular(4),
  718. ),
  719. child: TDText(
  720. label,
  721. font: tdTheme.fontBodySmall,
  722. fontWeight: FontWeight.w500,
  723. textColor: tdTheme.brandColor7,
  724. ),
  725. );
  726. }
  727. Future<void> _showDetailDialog({int? editIndex}) async {
  728. if (_addingDetail) return;
  729. _addingDetail = true;
  730. try {
  731. final l10n = AppLocalizations.of(context);
  732. OvertimeDetailData? initialData;
  733. if (editIndex != null) {
  734. final d = _details[editIndex];
  735. initialData = OvertimeDetailData(
  736. jbNo: d.jbNo,
  737. itm: d.itm > 0 ? d.itm : null,
  738. salNo: d.salNo,
  739. salName: d.salName,
  740. dep: d.dep,
  741. depName: d.depName,
  742. jbType: d.jbType,
  743. jbDate: d.jbDate,
  744. startTime: d.startTime,
  745. endTime: d.endTime,
  746. jbHours: d.jbHours,
  747. jbDays: d.jbDays,
  748. attPeriod: d.attPeriod,
  749. reason: d.reason,
  750. compensationType: d.compensationType,
  751. compensationCount: d.compensationCount,
  752. adr: d.adr,
  753. rem: d.rem,
  754. );
  755. }
  756. FocusManager.instance.primaryFocus?.unfocus();
  757. final result = await OvertimeApplyDetailDialog.show(
  758. // ignore: use_build_context_synchronously
  759. context,
  760. api: ref.read(overtimeApplyApiProvider),
  761. l10n: l10n,
  762. initialData: initialData,
  763. );
  764. if (result != null && mounted) {
  765. setState(() {
  766. final item = _DetailItem(
  767. id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
  768. jbNo: result.jbNo,
  769. itm: result.itm ?? 0,
  770. salNo: result.salNo,
  771. salName: result.salName,
  772. dep: result.dep,
  773. depName: result.depName,
  774. jbType: result.jbType,
  775. jbDate: result.jbDate,
  776. startTime: result.startTime,
  777. endTime: result.endTime,
  778. jbHours: result.jbHours,
  779. jbDays: result.jbDays,
  780. attPeriod: result.attPeriod,
  781. reason: result.reason,
  782. compensationType: result.compensationType,
  783. compensationCount: result.compensationCount,
  784. adr: result.adr,
  785. rem: result.rem,
  786. );
  787. if (editIndex != null) {
  788. _details[editIndex] = item;
  789. } else {
  790. _details.add(item);
  791. }
  792. });
  793. }
  794. } finally {
  795. _addingDetail = false;
  796. }
  797. }
  798. // ═══ 3. 底部操作栏 ═══
  799. Widget _buildBottomBar(AppLocalizations l10n) {
  800. return ActionBar(
  801. showLeft: false,
  802. centerLabel: l10n.get('saveDraft'),
  803. rightLabel: l10n.get('submit'),
  804. centerTextOnly: true,
  805. onCenterTap: () async {
  806. FocusScope.of(context).unfocus();
  807. try {
  808. await _saveDraftToStorage();
  809. if (mounted) _forcePop();
  810. } catch (_) {
  811. if (mounted) {
  812. TDToast.showFail(l10n.get('saveFailed'), context: context);
  813. }
  814. }
  815. },
  816. onRightTap: () async {
  817. final err = _validate(l10n);
  818. if (err.isNotEmpty) {
  819. TDToast.showText(err.first, context: context);
  820. return;
  821. }
  822. FocusScope.of(context).unfocus();
  823. LoadingDialog.show(context, text: l10n.get('submitting'));
  824. try {
  825. final data = _buildSubmitData();
  826. final api = ref.read(overtimeApplyApiProvider);
  827. final billNo = await api.submit(data);
  828. await DraftStorage.delete(_draftKey);
  829. if (!mounted) return;
  830. LoadingDialog.hide(context);
  831. if (billNo != null) {
  832. final dd = data['HeadData']['JB_DD']?.toString() ?? '';
  833. await AuditFlowHelper.handle(
  834. context: context,
  835. l10n: l10n,
  836. getConfig: () => api.getBillAuditConfig('JB'),
  837. onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd),
  838. );
  839. }
  840. if (mounted) {
  841. TDToast.showSuccess(l10n.get('billCreated'), context: context);
  842. ref.read(overtimeApplyRefreshProvider.notifier).state++;
  843. GoRouter.of(context).go('/overtime-apply/list');
  844. }
  845. } catch (e) {
  846. if (mounted) LoadingDialog.hide(context);
  847. }
  848. },
  849. );
  850. }
  851. Map<String, dynamic> _buildSubmitData() {
  852. final now = DateTime.now();
  853. final jbDd =
  854. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  855. return {
  856. 'HeadData': {
  857. 'JB_DD': jbDd,
  858. 'SAL_NO': _selectedApplicantId.isNotEmpty
  859. ? _selectedApplicantId
  860. : HostAppChannel.usr,
  861. 'DEP': _selectedDeptId,
  862. 'REASON': _reasonController.text.trim(),
  863. 'REM': _remarkController.text,
  864. 'USR': HostAppChannel.usr,
  865. },
  866. 'BodyData1': _details.asMap().entries.map((e) {
  867. final d = e.value;
  868. return {
  869. 'ITM': e.key + 1,
  870. 'SAL_NO': d.salNo,
  871. 'DEP': d.dep.isNotEmpty ? d.dep : _selectedDeptId,
  872. 'JB_TYPE': d.jbType,
  873. 'JB_DATE': d.jbDate,
  874. 'START_TIME': d.startTime,
  875. 'END_TIME': d.endTime,
  876. 'JB_HOURS': d.jbHours,
  877. 'JB_DAYS': d.jbDays,
  878. 'ATT_PERIOD': d.attPeriod,
  879. 'REASON': d.reason,
  880. 'COMPENSATION_TYPE': d.compensationType,
  881. 'COMPENSATION_COUNT': d.compensationCount,
  882. 'ADR': d.adr,
  883. 'REM': d.rem,
  884. };
  885. }).toList(),
  886. };
  887. }
  888. List<String> _validate(AppLocalizations l10n) {
  889. final e = <String>[];
  890. if (_reasonController.text.trim().isEmpty) {
  891. e.add(l10n.get('enterOvertimeReason'));
  892. }
  893. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneOTDetail'));
  894. if (_selectedDeptId.isEmpty) e.add(l10n.get('selectDept'));
  895. if (_selectedApplicantId.isEmpty) e.add(l10n.get('selectApplicant'));
  896. return e;
  897. }
  898. void _doPop() {
  899. if (_hasUnsaved()) {
  900. final l10n = AppLocalizations.of(context);
  901. _showConfirmDialog(
  902. l10n.get('confirmExit'),
  903. l10n.get('unsavedContentWarning'),
  904. l10n.get('continueEditing'),
  905. l10n.get('discardAndExit'),
  906. () async {
  907. await DraftStorage.delete(_draftKey);
  908. if (!mounted) return;
  909. setState(() => _clearLocalState());
  910. _forcePop();
  911. },
  912. );
  913. } else {
  914. _forcePop();
  915. }
  916. }
  917. void _forcePop() {
  918. FocusManager.instance.primaryFocus?.unfocus();
  919. final router = GoRouter.of(context);
  920. if (router.canPop()) {
  921. router.pop();
  922. } else {
  923. SystemNavigator.pop();
  924. }
  925. }
  926. bool _hasUnsaved() =>
  927. _reasonController.text.isNotEmpty ||
  928. _details.isNotEmpty ||
  929. _remarkController.text.isNotEmpty;
  930. void _clearLocalState() {
  931. _reasonController.clear();
  932. _remarkController.clear();
  933. _details.clear();
  934. _detailIdCounter = 1;
  935. _selectedDeptId = '';
  936. _selectedDeptName = '';
  937. _selectedApplicantId = '';
  938. _selectedApplicantName = '';
  939. }
  940. void _unfocus() => FocusScope.of(context).unfocus();
  941. void _showConfirmDialog(
  942. String title,
  943. String content,
  944. String leftText,
  945. String rightText,
  946. VoidCallback onConfirm,
  947. ) {
  948. _unfocus();
  949. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  950. showDialog(
  951. context: context,
  952. useRootNavigator: true,
  953. builder: (ctx) => TDAlertDialog(
  954. title: title,
  955. content: content,
  956. buttonStyle: TDDialogButtonStyle.text,
  957. leftBtn: TDDialogButtonOptions(
  958. title: leftText,
  959. titleColor: colors.primary,
  960. action: () => Navigator.pop(ctx),
  961. ),
  962. rightBtn: TDDialogButtonOptions(
  963. title: rightText,
  964. titleColor: colors.danger,
  965. action: () {
  966. Navigator.pop(ctx);
  967. onConfirm();
  968. },
  969. ),
  970. ),
  971. );
  972. }
  973. Widget _label(String t, {bool required = false}) {
  974. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  975. return Text.rich(
  976. TextSpan(
  977. children: [
  978. TextSpan(
  979. text: t,
  980. style: TextStyle(
  981. fontSize: AppFontSizes.subtitle,
  982. color: colors.textSecondary,
  983. ),
  984. ),
  985. if (required)
  986. TextSpan(
  987. text: ' *',
  988. style: TextStyle(
  989. fontSize: AppFontSizes.subtitle,
  990. color: colors.danger,
  991. ),
  992. ),
  993. ],
  994. ),
  995. );
  996. }
  997. Widget _buildPageFooter() {
  998. final l10n = AppLocalizations.of(context);
  999. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1000. return Center(
  1001. child: Padding(
  1002. padding: const EdgeInsets.only(bottom: 16),
  1003. child: Row(
  1004. mainAxisSize: MainAxisSize.min,
  1005. children: [
  1006. Icon(
  1007. Icons.rocket_launch_outlined,
  1008. size: 16,
  1009. color: colors.textPlaceholder,
  1010. ),
  1011. const SizedBox(width: 6),
  1012. Text(
  1013. l10n.get('pageFooter'),
  1014. style: TextStyle(
  1015. fontSize: AppFontSizes.caption,
  1016. color: colors.textPlaceholder,
  1017. ),
  1018. ),
  1019. ],
  1020. ),
  1021. ),
  1022. );
  1023. }
  1024. Future<void> _showDeptPicker() async {
  1025. FocusManager.instance.primaryFocus?.unfocus();
  1026. final l10n = AppLocalizations.of(context);
  1027. final api = ref.read(overtimeApplyApiProvider);
  1028. final result = await showSearchablePicker<DepartmentItem>(
  1029. context,
  1030. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  1031. searchHint: l10n.get('search'),
  1032. loader: (keyword, page) =>
  1033. api.getDepartments(keyword: keyword, page: page, size: 20),
  1034. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  1035. onRefresh: () => api.clearRefCache(),
  1036. );
  1037. if (result != null && mounted) {
  1038. setState(() {
  1039. _selectedDeptId = result.dep;
  1040. _selectedDeptName = result.name;
  1041. });
  1042. }
  1043. }
  1044. }
  1045. class _DetailItem {
  1046. final int id;
  1047. final String? jbNo;
  1048. final int itm;
  1049. final String salNo;
  1050. final String salName;
  1051. final String dep;
  1052. final String depName;
  1053. final String jbType;
  1054. final String jbDate;
  1055. final String startTime;
  1056. final String endTime;
  1057. final double jbHours;
  1058. final double jbDays;
  1059. final String attPeriod;
  1060. final String reason;
  1061. final String compensationType;
  1062. final double compensationCount;
  1063. final String adr;
  1064. final String rem;
  1065. const _DetailItem({
  1066. required this.id,
  1067. this.jbNo,
  1068. this.itm = 0,
  1069. this.salNo = '',
  1070. this.salName = '',
  1071. this.dep = '',
  1072. this.depName = '',
  1073. this.jbType = 'WORKING_DAY',
  1074. this.jbDate = '',
  1075. this.startTime = '',
  1076. this.endTime = '',
  1077. this.jbHours = 0.0,
  1078. this.jbDays = 0.0,
  1079. this.attPeriod = '',
  1080. this.reason = '',
  1081. this.compensationType = '',
  1082. this.compensationCount = 0.0,
  1083. this.adr = '',
  1084. this.rem = '',
  1085. });
  1086. }