overtime_create_page.dart 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969
  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 '../../core/theme/app_colors.dart';
  11. import '../../core/theme/app_colors_extension.dart';
  12. import '../../core/utils/amount_utils.dart';
  13. import '../../shared/widgets/action_bar.dart';
  14. import '../../shared/widgets/app_skeletons.dart';
  15. import '../../shared/widgets/form_field_row.dart';
  16. import '../../shared/widgets/form_section.dart';
  17. import '../../shared/widgets/loading_dialog.dart';
  18. import '../../shared/widgets/nav_bar_config.dart';
  19. import '../../shared/widgets/searchable_picker_sheet.dart';
  20. import '../expense_apply/expense_apply_api.dart';
  21. import 'overtime_api.dart';
  22. import 'widgets/overtime_detail_dialog.dart';
  23. class OvertimeCreatePage extends ConsumerStatefulWidget {
  24. final String? id;
  25. const OvertimeCreatePage({super.key, this.id});
  26. @override
  27. ConsumerState<OvertimeCreatePage> createState() => _OvertimeCreatePageState();
  28. }
  29. class _OvertimeCreatePageState extends ConsumerState<OvertimeCreatePage> {
  30. static const _draftKey = 'overtime_apply';
  31. // ── 基本信息 ──
  32. String _billType = '工作日加班';
  33. String _selectedDeptId = '';
  34. String _selectedDeptName = '';
  35. String _selectedSalesmanId = '';
  36. String _selectedSalesmanName = '';
  37. final _customerController = TextEditingController();
  38. final _origBillNoController = TextEditingController();
  39. bool _isClosed = false;
  40. final _feedbackController = TextEditingController();
  41. final _remarkController = TextEditingController();
  42. final _scrollCtrl = ScrollController();
  43. // ── 明细 ──
  44. final List<OvertimeDetailData> _details = [];
  45. // ── 草稿 ──
  46. late Future<bool> _draftFuture;
  47. bool _draftHandled = false;
  48. // ── 参考数据 ──
  49. List<DepartmentItem> _departments = [];
  50. bool _firstBuild = true;
  51. bool _refDataLoading = true;
  52. bool _addingDetail = false;
  53. @override
  54. void initState() {
  55. super.initState();
  56. SystemChrome.setSystemUIOverlayStyle(
  57. const SystemUiOverlayStyle(
  58. statusBarColor: Colors.transparent,
  59. statusBarIconBrightness: Brightness.dark,
  60. ),
  61. );
  62. _departments = [];
  63. _refDataLoading = true;
  64. _draftFuture = DraftStorage.has(_draftKey);
  65. _loadRefData();
  66. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  67. }
  68. void _checkDataReady() {
  69. if (!_refDataLoading && mounted) {
  70. setState(() => _firstBuild = false);
  71. WidgetsBinding.instance.addPostFrameCallback((_) {
  72. if (mounted) setState(() {});
  73. });
  74. } else if (mounted) {
  75. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  76. }
  77. }
  78. Future<void> _loadRefData({bool showLoading = false}) async {
  79. if (showLoading) {
  80. LoadingDialog.show(
  81. context,
  82. text: AppLocalizations.of(context).get('dataLoading'),
  83. );
  84. }
  85. try {
  86. final api = ref.read(overtimeApiProvider);
  87. final deps = await api.getDepartments();
  88. if (!mounted) return;
  89. setState(() {
  90. _departments = deps;
  91. _refDataLoading = false;
  92. _autoSelectDept();
  93. });
  94. } catch (_) {
  95. if (!mounted) return;
  96. setState(() => _refDataLoading = false);
  97. } finally {
  98. if (showLoading && mounted) LoadingDialog.hide(context);
  99. }
  100. }
  101. void _autoSelectDept() {
  102. if (_selectedDeptId.isNotEmpty) return;
  103. final dep = HostAppChannel.dep;
  104. if (dep.isEmpty) return;
  105. final match = _departments.where((d) => d.dep == dep);
  106. if (match.isNotEmpty) {
  107. _selectedDeptId = match.first.dep;
  108. _selectedDeptName = match.first.name;
  109. }
  110. }
  111. @override
  112. void dispose() {
  113. _customerController.dispose();
  114. _origBillNoController.dispose();
  115. _feedbackController.dispose();
  116. _remarkController.dispose();
  117. _scrollCtrl.dispose();
  118. super.dispose();
  119. }
  120. // ═══ 草稿持久化 ═══
  121. Future<void> _restoreDraft() async {
  122. final data = await DraftStorage.load(_draftKey);
  123. if (data == null) return;
  124. setState(() {
  125. _billType = data['billType'] as String? ?? '工作日加班';
  126. _selectedDeptId = data['selectedDeptId'] as String? ?? '';
  127. _selectedDeptName = data['selectedDeptName'] as String? ?? '';
  128. _selectedSalesmanId = data['selectedSalesmanId'] as String? ?? '';
  129. _selectedSalesmanName = data['selectedSalesmanName'] as String? ?? '';
  130. _customerController.text = data['customer'] as String? ?? '';
  131. _origBillNoController.text = data['origBillNo'] as String? ?? '';
  132. _isClosed = data['isClosed'] as bool? ?? false;
  133. _feedbackController.text = data['feedback'] as String? ?? '';
  134. _remarkController.text = data['remark'] as String? ?? '';
  135. _details.clear();
  136. final detailList = data['details'] as List<dynamic>?;
  137. if (detailList != null) {
  138. for (final d in detailList) {
  139. final m = d as Map<String, dynamic>;
  140. _details.add(
  141. OvertimeDetailData(
  142. empCode: m['empCode'] as String? ?? '',
  143. shiftType: m['shiftType'] as String? ?? '',
  144. startDate: m['startDate'] as String? ?? '',
  145. endDate: m['endDate'] as String? ?? '',
  146. approvedHours: (m['approvedHours'] as num?)?.toDouble() ?? 0,
  147. isApproved: m['isApproved'] as String? ?? 'N',
  148. directOt: m['directOt'] as String? ?? 'N',
  149. otQuantity: (m['otQuantity'] as num?)?.toDouble() ?? 0,
  150. reason: m['reason'] as String? ?? '',
  151. handleMethod: m['handleMethod'] as String? ?? '',
  152. chargeMethod: m['chargeMethod'] as String? ?? '',
  153. remark: m['remark'] as String? ?? '',
  154. isClosed: m['isClosed'] as String? ?? 'N',
  155. outBillNo: m['outBillNo'] as String? ?? '',
  156. ),
  157. );
  158. }
  159. }
  160. });
  161. }
  162. Future<void> _saveDraftToStorage() async {
  163. final detailList = _details
  164. .map(
  165. (d) => {
  166. 'empCode': d.empCode,
  167. 'shiftType': d.shiftType,
  168. 'startDate': d.startDate,
  169. 'endDate': d.endDate,
  170. 'approvedHours': d.approvedHours,
  171. 'isApproved': d.isApproved,
  172. 'directOt': d.directOt,
  173. 'otQuantity': d.otQuantity,
  174. 'reason': d.reason,
  175. 'handleMethod': d.handleMethod,
  176. 'chargeMethod': d.chargeMethod,
  177. 'remark': d.remark,
  178. 'isClosed': d.isClosed,
  179. 'outBillNo': d.outBillNo,
  180. },
  181. )
  182. .toList();
  183. await DraftStorage.save(_draftKey, {
  184. 'billType': _billType,
  185. 'selectedDeptId': _selectedDeptId,
  186. 'selectedDeptName': _selectedDeptName,
  187. 'selectedSalesmanId': _selectedSalesmanId,
  188. 'selectedSalesmanName': _selectedSalesmanName,
  189. 'customer': _customerController.text,
  190. 'origBillNo': _origBillNoController.text,
  191. 'isClosed': _isClosed,
  192. 'feedback': _feedbackController.text,
  193. 'remark': _remarkController.text,
  194. 'details': detailList,
  195. });
  196. }
  197. // ═══ 草稿弹窗 ═══
  198. void _showDraftDialog() {
  199. final l10n = AppLocalizations.of(context);
  200. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  201. FocusManager.instance.primaryFocus?.unfocus();
  202. showDialog(
  203. context: context,
  204. barrierDismissible: false,
  205. builder: (ctx) => TDAlertDialog(
  206. title: l10n.get('draftFound'),
  207. content: l10n.get('draftRestorePrompt'),
  208. leftBtn: TDDialogButtonOptions(
  209. title: l10n.get('discard'),
  210. titleColor: colors.textSecondary,
  211. action: () {
  212. Navigator.pop(ctx);
  213. DraftStorage.delete(_draftKey);
  214. },
  215. ),
  216. rightBtn: TDDialogButtonOptions(
  217. title: l10n.get('restore'),
  218. titleColor: colors.primary,
  219. action: () {
  220. Navigator.pop(ctx);
  221. _restoreDraft();
  222. },
  223. ),
  224. ),
  225. );
  226. }
  227. @override
  228. Widget build(BuildContext context) {
  229. final l10n = AppLocalizations.of(context);
  230. if (_firstBuild) {
  231. return const SkeletonFormPage();
  232. }
  233. Future.microtask(
  234. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  235. );
  236. return FutureBuilder<bool>(
  237. future: _draftFuture,
  238. builder: (ctx, snapshot) {
  239. final hasDraft = snapshot.hasData && snapshot.data == true;
  240. if (hasDraft && !_draftHandled) {
  241. _draftHandled = true;
  242. WidgetsBinding.instance.addPostFrameCallback((_) {
  243. if (mounted) _showDraftDialog();
  244. });
  245. }
  246. Future.microtask(
  247. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  248. );
  249. return PopScope(
  250. canPop: false,
  251. onPopInvokedWithResult: (didPop, _) {
  252. if (didPop) return;
  253. _doPop();
  254. },
  255. child: Column(
  256. children: [
  257. Expanded(
  258. child: GestureDetector(
  259. onTap: () => FocusScope.of(context).unfocus(),
  260. child: SingleChildScrollView(
  261. controller: _scrollCtrl,
  262. padding: const EdgeInsets.all(16),
  263. child: Column(
  264. children: [
  265. _buildBasicInfo(l10n),
  266. const SizedBox(height: 16),
  267. _buildDetailsSection(l10n),
  268. const SizedBox(height: 24),
  269. _buildPageFooter(),
  270. ],
  271. ),
  272. ),
  273. ),
  274. ),
  275. _buildBottomBar(l10n),
  276. ],
  277. ),
  278. );
  279. },
  280. );
  281. }
  282. // ═══ 1. 基本信息 ═══
  283. Widget _buildBasicInfo(AppLocalizations l10n) {
  284. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  285. return FormSection(
  286. title: l10n.get('basicInfo'),
  287. leadingIcon: Icons.info_outline,
  288. children: [
  289. FormFieldRow(
  290. label: l10n.get('date'),
  291. value: _today(),
  292. readOnly: true,
  293. showArrow: false,
  294. ),
  295. const SizedBox(height: 16),
  296. FormFieldRow(
  297. label: l10n.get('applyDept'),
  298. value: _selectedDeptId.isNotEmpty
  299. ? '$_selectedDeptId/$_selectedDeptName'
  300. : '',
  301. hint: l10n.get('pleaseSelect'),
  302. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  303. ),
  304. const SizedBox(height: 16),
  305. _buildBillTypeRow(l10n),
  306. const SizedBox(height: 16),
  307. FormFieldRow(
  308. label: '业务员',
  309. value: _selectedSalesmanId.isNotEmpty
  310. ? '$_selectedSalesmanId/$_selectedSalesmanName'
  311. : '',
  312. hint: l10n.get('pleaseSelect'),
  313. onTap: () => _showSalesmanPicker(),
  314. ),
  315. const SizedBox(height: 16),
  316. FormFieldRow(
  317. label: '报修客户',
  318. value: _customerController.text,
  319. hint: '请输入',
  320. onTap: () => _showTextInput(
  321. '报修客户',
  322. (v) => setState(() {
  323. _customerController.text = v;
  324. _customerController.selection = TextSelection.fromPosition(
  325. TextPosition(offset: v.length),
  326. );
  327. }),
  328. initialText: _customerController.text,
  329. ),
  330. ),
  331. const SizedBox(height: 16),
  332. FormFieldRow(
  333. label: '原加班单号',
  334. value: _origBillNoController.text,
  335. hint: '请输入',
  336. onTap: () => _showTextInput(
  337. '原加班单号',
  338. (v) => setState(() {
  339. _origBillNoController.text = v;
  340. _origBillNoController.selection = TextSelection.fromPosition(
  341. TextPosition(offset: v.length),
  342. );
  343. }),
  344. initialText: _origBillNoController.text,
  345. ),
  346. ),
  347. const SizedBox(height: 16),
  348. Row(
  349. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  350. children: [
  351. Text(
  352. '结案',
  353. style: TextStyle(
  354. fontSize: AppFontSizes.subtitle,
  355. color: colors.textSecondary,
  356. ),
  357. ),
  358. TDSwitch(
  359. isOn: _isClosed,
  360. onChanged: (v) {
  361. setState(() => _isClosed = v);
  362. return false;
  363. },
  364. ),
  365. ],
  366. ),
  367. const SizedBox(height: 16),
  368. _label(l10n.get('reminderTitle')),
  369. const SizedBox(height: 8),
  370. TDTextarea(
  371. controller: _feedbackController,
  372. hintText: '客户反馈',
  373. maxLines: 3,
  374. minLines: 1,
  375. maxLength: 500,
  376. indicator: true,
  377. padding: EdgeInsets.zero,
  378. bordered: true,
  379. backgroundColor: colors.bgPage,
  380. ),
  381. const SizedBox(height: 16),
  382. _label(l10n.get('remark')),
  383. const SizedBox(height: 8),
  384. TDTextarea(
  385. controller: _remarkController,
  386. hintText: l10n.get('enterRemark'),
  387. maxLines: 3,
  388. minLines: 1,
  389. maxLength: 500,
  390. indicator: true,
  391. padding: EdgeInsets.zero,
  392. bordered: true,
  393. backgroundColor: colors.bgPage,
  394. ),
  395. ],
  396. );
  397. }
  398. Widget _buildBillTypeRow(AppLocalizations l10n) {
  399. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  400. final types = ['工作日加班', '休息日加班', '节假日加班'];
  401. return Row(
  402. children: [
  403. Text(
  404. '单据类别',
  405. style: TextStyle(
  406. fontSize: AppFontSizes.subtitle,
  407. color: colors.textSecondary,
  408. ),
  409. ),
  410. const SizedBox(width: 8),
  411. Expanded(
  412. child: Wrap(
  413. alignment: WrapAlignment.end,
  414. crossAxisAlignment: WrapCrossAlignment.center,
  415. spacing: 12,
  416. runSpacing: 8,
  417. children: types.map((t) {
  418. final sel = _billType == t;
  419. return GestureDetector(
  420. behavior: HitTestBehavior.opaque,
  421. onTap: () => setState(() => _billType = t),
  422. child: Row(
  423. mainAxisSize: MainAxisSize.min,
  424. children: [
  425. Container(
  426. width: 18,
  427. height: 18,
  428. decoration: BoxDecoration(
  429. shape: BoxShape.circle,
  430. border: Border.all(
  431. color: sel ? colors.primary : colors.textPlaceholder,
  432. width: 2,
  433. ),
  434. ),
  435. child: sel
  436. ? Center(
  437. child: Container(
  438. width: 8,
  439. height: 8,
  440. decoration: BoxDecoration(
  441. shape: BoxShape.circle,
  442. color: colors.primary,
  443. ),
  444. ),
  445. )
  446. : null,
  447. ),
  448. const SizedBox(width: 5),
  449. Text(
  450. t,
  451. style: TextStyle(
  452. fontSize: AppFontSizes.subtitle,
  453. color: sel ? colors.primary : colors.textPrimary,
  454. ),
  455. ),
  456. ],
  457. ),
  458. );
  459. }).toList(),
  460. ),
  461. ),
  462. ],
  463. );
  464. }
  465. // ═══ 2. 明细 ═══
  466. Widget _buildDetailsSection(AppLocalizations l10n) {
  467. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  468. return FormSection(
  469. title: '加班明细',
  470. leadingIcon: Icons.receipt_long_outlined,
  471. showAction: true,
  472. actionText: '添加',
  473. onActionTap: _showDetailDialog,
  474. children: [
  475. if (_details.isEmpty)
  476. Padding(
  477. padding: const EdgeInsets.symmetric(vertical: 8),
  478. child: Text(
  479. '请添加加班明细',
  480. style: TextStyle(
  481. fontSize: AppFontSizes.subtitle,
  482. color: colors.textPlaceholder,
  483. ),
  484. ),
  485. )
  486. else
  487. ..._details.asMap().entries.map((e) {
  488. final d = e.value;
  489. return GestureDetector(
  490. onTap: () => _showDetailDialog(editIndex: e.key),
  491. child: Container(
  492. margin: const EdgeInsets.symmetric(vertical: 8),
  493. padding: const EdgeInsets.all(12),
  494. decoration: BoxDecoration(
  495. color: colors.bgPage,
  496. borderRadius: BorderRadius.circular(8),
  497. ),
  498. child: Row(
  499. crossAxisAlignment: CrossAxisAlignment.center,
  500. children: [
  501. Expanded(
  502. child: Column(
  503. crossAxisAlignment: CrossAxisAlignment.start,
  504. children: [
  505. Row(
  506. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  507. children: [
  508. Expanded(
  509. child: Text(
  510. '${d.empCode}${d.shiftType.isNotEmpty ? ' · ${d.shiftType}' : ''}',
  511. maxLines: 1,
  512. overflow: TextOverflow.ellipsis,
  513. style: TextStyle(
  514. fontSize: AppFontSizes.subtitle,
  515. color: colors.textPrimary,
  516. ),
  517. ),
  518. ),
  519. const SizedBox(width: 12),
  520. Text(
  521. formatAmount(d.approvedHours),
  522. style: TextStyle(
  523. fontSize: AppFontSizes.caption,
  524. fontWeight: FontWeight.w600,
  525. color: colors.amountPrimary,
  526. ),
  527. ),
  528. ],
  529. ),
  530. if (d.startDate.isNotEmpty &&
  531. d.endDate.isNotEmpty) ...[
  532. const SizedBox(height: 4),
  533. Text(
  534. '${d.startDate} ~ ${d.endDate}',
  535. maxLines: 1,
  536. overflow: TextOverflow.ellipsis,
  537. style: TextStyle(
  538. fontSize: AppFontSizes.caption,
  539. color: colors.textSecondary,
  540. ),
  541. ),
  542. ],
  543. if (d.reason.isNotEmpty) ...[
  544. const SizedBox(height: 4),
  545. Text(
  546. d.reason,
  547. maxLines: 1,
  548. overflow: TextOverflow.ellipsis,
  549. style: TextStyle(
  550. fontSize: AppFontSizes.caption,
  551. color: colors.textSecondary,
  552. ),
  553. ),
  554. ],
  555. if (d.isApproved == 'Y') ...[
  556. const SizedBox(height: 4),
  557. Text(
  558. '已核准',
  559. style: TextStyle(
  560. fontSize: AppFontSizes.caption,
  561. color: colors.success,
  562. ),
  563. ),
  564. ],
  565. ],
  566. ),
  567. ),
  568. const SizedBox(width: 8),
  569. GestureDetector(
  570. onTap: () => setState(() => _details.removeAt(e.key)),
  571. child: Icon(
  572. Icons.close,
  573. size: 18,
  574. color: colors.textSecondary,
  575. ),
  576. ),
  577. ],
  578. ),
  579. ),
  580. );
  581. }),
  582. const SizedBox(height: 8),
  583. Container(
  584. padding: const EdgeInsets.symmetric(vertical: 8),
  585. child: Row(
  586. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  587. children: [
  588. Text(
  589. '合计',
  590. style: TextStyle(
  591. fontSize: AppFontSizes.body,
  592. fontWeight: FontWeight.w600,
  593. color: colors.textPrimary,
  594. ),
  595. ),
  596. Text(
  597. formatAmount(
  598. _details.fold<double>(0, (s, d) => s + d.approvedHours),
  599. ),
  600. style: TextStyle(
  601. fontSize: AppFontSizes.subtitle,
  602. fontWeight: FontWeight.w700,
  603. color: colors.amountPrimary,
  604. ),
  605. ),
  606. ],
  607. ),
  608. ),
  609. ],
  610. );
  611. }
  612. Future<void> _showDetailDialog({int? editIndex}) async {
  613. if (_addingDetail) return;
  614. _addingDetail = true;
  615. try {
  616. final l10n = AppLocalizations.of(context);
  617. OvertimeDetailData? initialData;
  618. if (editIndex != null) {
  619. initialData = _details[editIndex];
  620. }
  621. FocusManager.instance.primaryFocus?.unfocus();
  622. final result = await OvertimeDetailDialog.show(
  623. // ignore: use_build_context_synchronously
  624. context,
  625. l10n: l10n,
  626. initialData: initialData,
  627. );
  628. if (result != null && mounted) {
  629. setState(() {
  630. if (editIndex != null) {
  631. _details[editIndex] = result;
  632. } else {
  633. _details.add(result);
  634. }
  635. });
  636. }
  637. } finally {
  638. _addingDetail = false;
  639. }
  640. }
  641. // ═══ 3. 底部操作栏 ═══
  642. Widget _buildBottomBar(AppLocalizations l10n) {
  643. return ActionBar(
  644. showLeft: false,
  645. centerLabel: l10n.get('saveDraft'),
  646. rightLabel: l10n.get('submit'),
  647. centerTextOnly: true,
  648. onCenterTap: () async {
  649. FocusScope.of(context).unfocus();
  650. try {
  651. await _saveDraftToStorage();
  652. if (mounted) _forcePop();
  653. } catch (_) {
  654. if (mounted) {
  655. TDToast.showFail(l10n.get('saveFailed'), context: context);
  656. }
  657. }
  658. },
  659. onRightTap: () async {
  660. FocusScope.of(context).unfocus();
  661. LoadingDialog.show(context, text: l10n.get('submitting'));
  662. try {
  663. final data = _buildSubmitData();
  664. final api = ref.read(overtimeApiProvider);
  665. await api.submit(data);
  666. await DraftStorage.delete(_draftKey);
  667. if (mounted) {
  668. LoadingDialog.hide(context);
  669. TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
  670. GoRouter.of(context).go('/overtime/list');
  671. }
  672. } catch (e) {
  673. if (mounted) {
  674. LoadingDialog.hide(context);
  675. TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
  676. }
  677. }
  678. },
  679. );
  680. }
  681. Map<String, dynamic> _buildSubmitData() {
  682. return {
  683. 'HeadData': {
  684. 'BILL_TYPE': _billType,
  685. 'DEP': _selectedDeptId,
  686. 'SALESMAN': _selectedSalesmanId,
  687. 'CUSTOMER': _customerController.text,
  688. 'ORIG_BILL_NO': _origBillNoController.text,
  689. 'IS_CLOSED': _isClosed ? 1 : 0,
  690. 'FEEDBACK': _feedbackController.text,
  691. 'REMARK': _remarkController.text,
  692. 'USR': HostAppChannel.usr,
  693. },
  694. 'BodyData1': _details.asMap().entries.map((e) {
  695. final d = e.value;
  696. return {
  697. 'SEQ_NO': e.key + 1,
  698. 'EMP_CODE': d.empCode,
  699. 'SHIFT_TYPE': d.shiftType,
  700. 'START_DATE': d.startDate,
  701. 'END_DATE': d.endDate,
  702. 'APPROVED_HOURS': d.approvedHours,
  703. 'IS_APPROVED': d.isApproved,
  704. 'DIRECT_OT': d.directOt,
  705. 'OT_QUANTITY': d.otQuantity,
  706. 'REASON': d.reason,
  707. 'HANDLE_METHOD': d.handleMethod,
  708. 'CHARGE_METHOD': d.chargeMethod,
  709. 'REMARK': d.remark,
  710. 'IS_CLOSED': d.isClosed,
  711. 'OUT_BILL_NO': d.outBillNo,
  712. };
  713. }).toList(),
  714. };
  715. }
  716. void _doPop() {
  717. if (_hasUnsaved()) {
  718. final l10n = AppLocalizations.of(context);
  719. _showConfirmDialog(
  720. l10n.get('confirmExit'),
  721. l10n.get('unsavedContentWarning'),
  722. l10n.get('continueEditing'),
  723. l10n.get('discardAndExit'),
  724. () async {
  725. await DraftStorage.delete(_draftKey);
  726. if (!mounted) return;
  727. setState(() => _clearLocalState());
  728. _forcePop();
  729. },
  730. );
  731. } else {
  732. _forcePop();
  733. }
  734. }
  735. void _forcePop() {
  736. FocusManager.instance.primaryFocus?.unfocus();
  737. final router = GoRouter.of(context);
  738. if (router.canPop()) {
  739. router.pop();
  740. } else {
  741. SystemNavigator.pop();
  742. }
  743. }
  744. bool _hasUnsaved() =>
  745. _customerController.text.isNotEmpty ||
  746. _origBillNoController.text.isNotEmpty ||
  747. _feedbackController.text.isNotEmpty ||
  748. _remarkController.text.isNotEmpty ||
  749. _details.isNotEmpty ||
  750. _billType != '工作日加班' ||
  751. _selectedDeptId.isNotEmpty ||
  752. _selectedSalesmanId.isNotEmpty ||
  753. _isClosed;
  754. void _clearLocalState() {
  755. _billType = '工作日加班';
  756. _selectedDeptId = '';
  757. _selectedDeptName = '';
  758. _selectedSalesmanId = '';
  759. _selectedSalesmanName = '';
  760. _customerController.clear();
  761. _origBillNoController.clear();
  762. _isClosed = false;
  763. _feedbackController.clear();
  764. _remarkController.clear();
  765. _details.clear();
  766. }
  767. void _unfocus() => FocusScope.of(context).unfocus();
  768. void _showConfirmDialog(
  769. String title,
  770. String content,
  771. String leftText,
  772. String rightText,
  773. VoidCallback onConfirm,
  774. ) {
  775. _unfocus();
  776. FocusManager.instance.primaryFocus?.unfocus();
  777. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  778. showDialog(
  779. context: context,
  780. useRootNavigator: true,
  781. builder: (ctx) => TDAlertDialog(
  782. title: title,
  783. content: content,
  784. buttonStyle: TDDialogButtonStyle.text,
  785. leftBtn: TDDialogButtonOptions(
  786. title: leftText,
  787. titleColor: colors.primary,
  788. action: () => Navigator.pop(ctx),
  789. ),
  790. rightBtn: TDDialogButtonOptions(
  791. title: rightText,
  792. titleColor: colors.danger,
  793. action: () {
  794. Navigator.pop(ctx);
  795. onConfirm();
  796. },
  797. ),
  798. ),
  799. );
  800. }
  801. // ═══ Picker 方法 ═══
  802. Future<void> _showDeptPicker() async {
  803. FocusManager.instance.primaryFocus?.unfocus();
  804. final l10n = AppLocalizations.of(context);
  805. final api = ref.read(overtimeApiProvider);
  806. final result = await showSearchablePicker<DepartmentItem>(
  807. context,
  808. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  809. searchHint: l10n.get('search'),
  810. loader: (keyword, page) =>
  811. api.getDepartments(keyword: keyword, page: page, size: 20),
  812. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  813. onRefresh: () => api.clearRefCache(),
  814. );
  815. if (result != null && mounted) {
  816. setState(() {
  817. _selectedDeptId = result.dep;
  818. _selectedDeptName = result.name;
  819. });
  820. }
  821. }
  822. Future<void> _showSalesmanPicker() async {
  823. FocusManager.instance.primaryFocus?.unfocus();
  824. final l10n = AppLocalizations.of(context);
  825. final api = ref.read(overtimeApiProvider);
  826. final result = await showSearchablePicker<EmployeeItem>(
  827. context,
  828. title: '${l10n.get('select')}业务员',
  829. searchHint: l10n.get('search'),
  830. loader: (keyword, page) =>
  831. api.getEmployees(keyword: keyword, page: page, size: 20),
  832. labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  833. onRefresh: () => api.clearRefCache(),
  834. );
  835. if (result != null && mounted) {
  836. setState(() {
  837. _selectedSalesmanId = result.salNo;
  838. _selectedSalesmanName = result.name;
  839. });
  840. }
  841. }
  842. void _showTextInput(
  843. String title,
  844. Function(String) onConfirm, {
  845. String initialText = '',
  846. }) {
  847. final l10n = AppLocalizations.of(context);
  848. _unfocus();
  849. FocusManager.instance.primaryFocus?.unfocus();
  850. final c = TextEditingController(text: initialText);
  851. showGeneralDialog(
  852. context: context,
  853. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  854. textEditingController: c,
  855. title: title,
  856. hintText: l10n.get('pleaseEnter'),
  857. leftBtn: TDDialogButtonOptions(
  858. title: l10n.get('cancel'),
  859. action: () => Navigator.pop(ctx),
  860. ),
  861. rightBtn: TDDialogButtonOptions(
  862. title: l10n.get('confirm'),
  863. action: () {
  864. onConfirm(c.text);
  865. Navigator.pop(ctx);
  866. },
  867. ),
  868. ),
  869. );
  870. }
  871. Widget _label(String t, {bool required = false}) {
  872. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  873. return Text.rich(
  874. TextSpan(
  875. children: [
  876. TextSpan(
  877. text: t,
  878. style: TextStyle(
  879. fontSize: AppFontSizes.subtitle,
  880. color: colors.textSecondary,
  881. ),
  882. ),
  883. if (required)
  884. TextSpan(
  885. text: ' *',
  886. style: TextStyle(
  887. fontSize: AppFontSizes.subtitle,
  888. color: colors.danger,
  889. ),
  890. ),
  891. ],
  892. ),
  893. );
  894. }
  895. Widget _buildPageFooter() {
  896. final l10n = AppLocalizations.of(context);
  897. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  898. return Center(
  899. child: Padding(
  900. padding: const EdgeInsets.only(bottom: 16),
  901. child: Row(
  902. mainAxisSize: MainAxisSize.min,
  903. children: [
  904. Icon(
  905. Icons.rocket_launch_outlined,
  906. size: 16,
  907. color: colors.textPlaceholder,
  908. ),
  909. const SizedBox(width: 6),
  910. Text(
  911. l10n.get('pageFooter'),
  912. style: TextStyle(
  913. fontSize: AppFontSizes.caption,
  914. color: colors.textPlaceholder,
  915. ),
  916. ),
  917. ],
  918. ),
  919. ),
  920. );
  921. }
  922. String _today() {
  923. final n = DateTime.now();
  924. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  925. }
  926. }