overtime_apply_detail_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../shared/widgets/app_skeletons.dart';
  10. import '../../shared/widgets/bill_status_bar.dart';
  11. import '../../shared/widgets/loading_dialog.dart';
  12. import '../../core/navigation/host_app_channel.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. import 'overtime_apply_model.dart';
  16. import 'widgets/overtime_apply_detail_view_dialog.dart';
  17. import 'overtime_apply_api.dart';
  18. class OvertimeApplyDetailPage extends ConsumerStatefulWidget {
  19. final String billNo;
  20. final int queryId;
  21. const OvertimeApplyDetailPage({
  22. super.key,
  23. required this.billNo,
  24. this.queryId = 0,
  25. });
  26. @override
  27. ConsumerState<OvertimeApplyDetailPage> createState() =>
  28. _OvertimeApplyDetailPageState();
  29. }
  30. class _OvertimeApplyDetailPageState
  31. extends ConsumerState<OvertimeApplyDetailPage> {
  32. bool _loading = true;
  33. String? _error;
  34. OvertimeApplyModel? _data;
  35. BillStatusBar? _billStatusBar;
  36. @override
  37. void initState() {
  38. super.initState();
  39. _loadData();
  40. }
  41. @override
  42. void dispose() {
  43. super.dispose();
  44. }
  45. Future<void> _loadData() async {
  46. setState(() {
  47. _loading = true;
  48. _error = null;
  49. });
  50. try {
  51. final api = ref.read(overtimeApplyApiProvider);
  52. final detail = await api.fetchDetail(widget.billNo);
  53. if (!mounted) return;
  54. // 单据状态 + 审核配置(非关键,尽力加载),与详情合并到一个 setState 避免中间闪烁
  55. Map<String, dynamic>? status;
  56. Map<String, dynamic>? auditConfig;
  57. try {
  58. final results = await Future.wait([
  59. api.getBillStatus(widget.billNo),
  60. api.getBillAuditConfig('JB'),
  61. ]);
  62. final Map<String, dynamic> s = results[0];
  63. final Map<String, dynamic> c = results[1];
  64. status = s;
  65. auditConfig = c;
  66. } catch (_) {}
  67. final hasAuditFlow = auditConfig?['hasAuditFlow'] == true;
  68. final autoSubmit = auditConfig?['autoSubmit'] == true;
  69. final canManualSubmit = hasAuditFlow && !autoSubmit;
  70. if (mounted) {
  71. setState(() {
  72. _data = detail;
  73. _billStatusBar = (status != null)
  74. ? BillStatusBar(
  75. billStatus: status,
  76. canManualSubmit: canManualSubmit,
  77. onEdit: () {
  78. context
  79. .push('/overtime-apply/edit/${widget.billNo}')
  80. .then((_) => _loadData());
  81. },
  82. onSubmit: () async {
  83. final l10n = AppLocalizations.of(context);
  84. final api = ref.read(overtimeApplyApiProvider);
  85. final jbDdStr = _data?.jbDd != null
  86. ? du.DateUtils.formatDate(_data!.jbDd!)
  87. : '';
  88. if (jbDdStr.isEmpty) return;
  89. LoadingDialog.show(context, text: l10n.get('submitting'));
  90. try {
  91. await api.shSubmit(bilNo: widget.billNo, bilDd: jbDdStr);
  92. } finally {
  93. if (mounted) LoadingDialog.hide(context);
  94. }
  95. if (mounted) _loadData();
  96. },
  97. onCancelSubmit: () async {
  98. final l10n = AppLocalizations.of(context);
  99. final api = ref.read(overtimeApplyApiProvider);
  100. final jbDdStr = _data?.jbDd != null
  101. ? du.DateUtils.formatDate(_data!.jbDd!)
  102. : '';
  103. if (jbDdStr.isEmpty) return;
  104. LoadingDialog.show(context, text: l10n.get('submitting'));
  105. try {
  106. await api.shSubmit(
  107. bilNo: widget.billNo,
  108. bilDd: jbDdStr,
  109. isCancel: true,
  110. );
  111. } finally {
  112. if (mounted) LoadingDialog.hide(context);
  113. }
  114. if (mounted) _loadData();
  115. },
  116. onTapStatusTag: () {
  117. _showAuditTrail('OT');
  118. },
  119. )
  120. : null;
  121. _loading = false;
  122. });
  123. }
  124. } catch (e) {
  125. if (mounted) {
  126. setState(() {
  127. _error = e.toString();
  128. _loading = false;
  129. });
  130. }
  131. }
  132. }
  133. @override
  134. Widget build(BuildContext context) {
  135. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  136. final l10n = AppLocalizations.of(context);
  137. if (_loading) {
  138. return const SkeletonDetailPage(sectionRows: [8, 3]);
  139. }
  140. if (_error != null) {
  141. return Center(
  142. child: Column(
  143. mainAxisSize: MainAxisSize.min,
  144. children: [
  145. Icon(Icons.error_outline, size: 48, color: colors.danger),
  146. const SizedBox(height: 16),
  147. Padding(
  148. padding: const EdgeInsets.symmetric(horizontal: 32),
  149. child: Text(
  150. _error!,
  151. textAlign: TextAlign.center,
  152. style: TextStyle(
  153. fontSize: AppFontSizes.body,
  154. color: colors.textSecondary,
  155. ),
  156. ),
  157. ),
  158. const SizedBox(height: 16),
  159. TDButton(
  160. text: l10n.get('retry'),
  161. size: TDButtonSize.medium,
  162. onTap: _loadData,
  163. ),
  164. ],
  165. ),
  166. );
  167. }
  168. final app = _data!;
  169. return Column(
  170. children: [
  171. Expanded(
  172. child: SingleChildScrollView(
  173. physics: const AlwaysScrollableScrollPhysics(),
  174. padding: const EdgeInsets.all(16),
  175. child: Column(
  176. children: [
  177. _buildBasicInfoSection(app, l10n, colors),
  178. const SizedBox(height: 16),
  179. _buildOvertimeDetailSection(app, l10n, colors),
  180. const SizedBox(height: 24),
  181. _buildPageFooter(colors),
  182. ],
  183. ),
  184. ),
  185. ),
  186. _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(),
  187. ],
  188. );
  189. }
  190. Future<void> _showAuditTrail(String billId) async {
  191. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  192. }
  193. // ═══ 基本信息 ═══
  194. Widget _buildBasicInfoSection(
  195. OvertimeApplyModel app,
  196. AppLocalizations l10n,
  197. AppColorsExtension colors,
  198. ) {
  199. return FormSection(
  200. title: l10n.get('basicInfo'),
  201. leadingIcon: Icons.info_outline,
  202. trailing: _billStatusBar?.buildStatusTag(context),
  203. children: [
  204. FormFieldRow(
  205. label: l10n.get('overtimeApplyNo'),
  206. value: app.jbNo,
  207. readOnly: true,
  208. showArrow: false,
  209. ),
  210. const SizedBox(height: 16),
  211. FormFieldRow(
  212. label: l10n.get('date'),
  213. value: app.jbDd != null ? du.DateUtils.formatDate(app.jbDd!) : '-',
  214. readOnly: true,
  215. showArrow: false,
  216. ),
  217. const SizedBox(height: 16),
  218. FormFieldRow(
  219. label: l10n.get('applicant'),
  220. value: app.salNo.isNotEmpty
  221. ? '${app.salNo}${app.salName.isNotEmpty ? '/${app.salName}' : ''}'
  222. : '-',
  223. readOnly: true,
  224. showArrow: false,
  225. ),
  226. const SizedBox(height: 16),
  227. FormFieldRow(
  228. label: l10n.get('dep'),
  229. value: app.dep.isNotEmpty
  230. ? '${app.dep}${app.depName.isNotEmpty ? '/${app.depName}' : ''}'
  231. : '-',
  232. readOnly: true,
  233. showArrow: false,
  234. ),
  235. const SizedBox(height: 16),
  236. FormFieldRow(
  237. label: l10n.get('overtimeReason'),
  238. value: app.reason.isNotEmpty ? app.reason : '-',
  239. readOnly: true,
  240. showArrow: false,
  241. bold: true,
  242. showMoreOnOverflow: true,
  243. ),
  244. const SizedBox(height: 16),
  245. FormFieldRow(
  246. label: l10n.get('remark'),
  247. value: app.rem.isNotEmpty ? app.rem : '-',
  248. readOnly: true,
  249. showArrow: false,
  250. bold: false,
  251. showMoreOnOverflow: true,
  252. ),
  253. ],
  254. );
  255. }
  256. // ═══ 加班明细 ═══
  257. Widget _buildOvertimeDetailSection(
  258. OvertimeApplyModel app,
  259. AppLocalizations l10n,
  260. AppColorsExtension colors,
  261. ) {
  262. return FormSection(
  263. title: l10n.get('overtimeDetails'),
  264. leadingIcon: Icons.access_time_outlined,
  265. children: [
  266. if (app.details.isEmpty)
  267. Padding(
  268. padding: const EdgeInsets.symmetric(vertical: 8),
  269. child: Text(
  270. l10n.get('noDetailData'),
  271. style: TextStyle(
  272. fontSize: AppFontSizes.body,
  273. color: colors.textPlaceholder,
  274. ),
  275. ),
  276. )
  277. else
  278. ...app.details.map((d) => _buildDetailCard(d, l10n, colors)),
  279. if (app.details.isNotEmpty) ...[
  280. const SizedBox(height: 8),
  281. Row(
  282. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  283. children: [
  284. Text(
  285. l10n.get('totalOvertimeHours'),
  286. style: TextStyle(
  287. fontSize: AppFontSizes.body,
  288. fontWeight: FontWeight.w600,
  289. color: colors.textPrimary,
  290. ),
  291. ),
  292. Text(
  293. '${_totalHours(app).toStringAsFixed(1)}${l10n.get('hours')}',
  294. style: TextStyle(
  295. fontSize: AppFontSizes.subtitle,
  296. fontWeight: FontWeight.w700,
  297. color: colors.timePrimary,
  298. ),
  299. ),
  300. ],
  301. ),
  302. ],
  303. ],
  304. );
  305. }
  306. double _totalHours(OvertimeApplyModel app) =>
  307. app.details.fold(0.0, (s, d) => s + d.jbHours);
  308. Widget _buildDetailCard(
  309. OvertimeApplyDetailModel d,
  310. AppLocalizations l10n,
  311. AppColorsExtension colors,
  312. ) {
  313. // 开始/结束时间格式化
  314. String formatDateTime(DateTime dt) {
  315. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  316. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
  317. }
  318. String formatHHmm(DateTime dt) {
  319. return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
  320. }
  321. return GestureDetector(
  322. onTap: () => OvertimeApplyDetailViewDialog.show(context, d),
  323. child: Container(
  324. margin: const EdgeInsets.symmetric(vertical: 6),
  325. padding: const EdgeInsets.all(12),
  326. decoration: BoxDecoration(
  327. color: colors.bgPage,
  328. borderRadius: BorderRadius.circular(8),
  329. ),
  330. child: Column(
  331. crossAxisAlignment: CrossAxisAlignment.start,
  332. children: [
  333. // 第一行:员工 + 时长
  334. Row(
  335. children: [
  336. Expanded(
  337. child: Text(
  338. '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}',
  339. maxLines: 1,
  340. overflow: TextOverflow.ellipsis,
  341. style: TextStyle(
  342. fontSize: AppFontSizes.body,
  343. fontWeight: FontWeight.w500,
  344. color: colors.textPrimary,
  345. ),
  346. ),
  347. ),
  348. Text(
  349. '${d.jbHours.toStringAsFixed(1)}${l10n.get('hours')}',
  350. style: TextStyle(
  351. fontSize: AppFontSizes.body,
  352. fontWeight: FontWeight.w600,
  353. color: colors.timePrimary,
  354. ),
  355. ),
  356. ],
  357. ),
  358. if (d.jbType.isNotEmpty) ...[
  359. const SizedBox(height: 2),
  360. _detailLabel(
  361. '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}',
  362. colors,
  363. ),
  364. if (d.jbDate != null)
  365. _detailLabel(
  366. '${l10n.get('jbDate')}: ${du.DateUtils.formatDate(d.jbDate!)}',
  367. colors,
  368. trailing: _weekdayTag(
  369. _weekdayLabel(d.jbDate!, l10n),
  370. ),
  371. ),
  372. ],
  373. if (d.startTime != null) ...[
  374. const SizedBox(height: 2),
  375. _detailLabel(
  376. '${l10n.get('startTime')}: ${formatHHmm(d.startTime!)}',
  377. colors,
  378. ),
  379. ],
  380. if (d.endTime != null) ...[
  381. const SizedBox(height: 2),
  382. _detailLabel(
  383. '${l10n.get('endTime')}: ${formatHHmm(d.endTime!)}',
  384. colors,
  385. ),
  386. ],
  387. // TODO: 加班天数暂时隐藏
  388. // if (d.jbDays > 0) ...[
  389. // const SizedBox(height: 2),
  390. // _detailLabel(
  391. // '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}',
  392. // colors,
  393. // ),
  394. // ],
  395. if (d.attPeriod.isNotEmpty) ...[
  396. const SizedBox(height: 2),
  397. _detailLabel('${l10n.get('attPeriod')}: ${d.attPeriod}', colors),
  398. ],
  399. // TODO: 补偿类型、补偿次数暂时隐藏
  400. // if (d.compensationType.isNotEmpty) ...[
  401. // const SizedBox(height: 2),
  402. // _detailLabel(
  403. // '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}',
  404. // colors,
  405. // ),
  406. // if (d.compensationType != 'NO_COMPENSATION' &&
  407. // d.compensationCount > 0)
  408. // _detailLabel(
  409. // '${l10n.get('compensationCount')}: ${d.compensationCount.toStringAsFixed(1)}',
  410. // colors,
  411. // ),
  412. // ],
  413. if (d.adr.isNotEmpty) ...[
  414. const SizedBox(height: 2),
  415. _detailLabel('${l10n.get('adr')}: ${d.adr}', colors),
  416. ],
  417. if (d.reason.isNotEmpty) ...[
  418. const SizedBox(height: 2),
  419. _detailLabel(
  420. '${l10n.get('overtimeDetailReason')}: ${d.reason}',
  421. colors,
  422. ),
  423. ],
  424. if (d.rem.isNotEmpty) ...[
  425. const SizedBox(height: 2),
  426. _detailLabel('${l10n.get('remark')}: ${d.rem}', colors),
  427. ],
  428. ],
  429. ),
  430. ),
  431. );
  432. }
  433. String _weekdayLabel(DateTime dt, AppLocalizations l10n) {
  434. switch (dt.weekday) {
  435. case 1:
  436. return l10n.get('monday');
  437. case 2:
  438. return l10n.get('tuesday');
  439. case 3:
  440. return l10n.get('wednesday');
  441. case 4:
  442. return l10n.get('thursday');
  443. case 5:
  444. return l10n.get('friday');
  445. case 6:
  446. return l10n.get('saturday');
  447. case 7:
  448. return l10n.get('sunday');
  449. default:
  450. return '';
  451. }
  452. }
  453. Widget _detailLabel(String text, AppColorsExtension colors, {Widget? trailing}) {
  454. return Padding(
  455. padding: const EdgeInsets.only(top: 2),
  456. child: Row(
  457. children: [
  458. Flexible(
  459. child: Text(
  460. text,
  461. maxLines: 2,
  462. overflow: TextOverflow.ellipsis,
  463. style: TextStyle(
  464. fontSize: AppFontSizes.caption,
  465. color: colors.textSecondary,
  466. ),
  467. ),
  468. ),
  469. if (trailing != null) ...[
  470. const SizedBox(width: 6),
  471. trailing,
  472. ],
  473. ],
  474. ),
  475. );
  476. }
  477. Widget _weekdayTag(String label) {
  478. final tdTheme = TDTheme.of(context);
  479. return Container(
  480. padding: const EdgeInsets.symmetric(horizontal: 6),
  481. decoration: BoxDecoration(
  482. color: tdTheme.brandColor1,
  483. borderRadius: BorderRadius.circular(4),
  484. ),
  485. child: TDText(
  486. label,
  487. font: tdTheme.fontBodySmall,
  488. fontWeight: FontWeight.w500,
  489. textColor: tdTheme.brandColor7,
  490. ),
  491. );
  492. }
  493. String _jbTypeLabel(String type, AppLocalizations l10n) {
  494. switch (type) {
  495. case 'WORKING_DAY':
  496. return l10n.get('workingDay');
  497. case 'REST_DAY':
  498. return l10n.get('restDay');
  499. case 'PUBLIC_HOLIDAY':
  500. return l10n.get('publicHoliday');
  501. case 'SPECIAL_HOLIDAY':
  502. return l10n.get('specialHoliday');
  503. case 'OTHER':
  504. return l10n.get('other');
  505. default:
  506. return type;
  507. }
  508. }
  509. String _compensationTypeLabel(String type, AppLocalizations l10n) {
  510. switch (type) {
  511. case 'OVERTIME_PAY':
  512. return l10n.get('overtimePay');
  513. case 'COMPENSATORY_LEAVE':
  514. return l10n.get('compensatoryLeave');
  515. case 'NO_COMPENSATION':
  516. return l10n.get('noCompensation');
  517. case 'OTHER':
  518. return l10n.get('other');
  519. default:
  520. return type;
  521. }
  522. }
  523. Widget _buildPageFooter(AppColorsExtension colors) {
  524. final l10n = AppLocalizations.of(context);
  525. return Center(
  526. child: Padding(
  527. padding: const EdgeInsets.only(bottom: 16),
  528. child: Row(
  529. mainAxisSize: MainAxisSize.min,
  530. children: [
  531. Icon(
  532. Icons.rocket_launch_outlined,
  533. size: 16,
  534. color: colors.textPlaceholder,
  535. ),
  536. const SizedBox(width: 6),
  537. Text(
  538. l10n.get('pageFooter'),
  539. style: TextStyle(
  540. fontSize: AppFontSizes.caption,
  541. color: colors.textPlaceholder,
  542. ),
  543. ),
  544. ],
  545. ),
  546. ),
  547. );
  548. }
  549. }